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

- Introduced billing capabilities, allowing users to request payments for subscription periods (3/6/12 months) with admin approval via Telegram.
- Updated role management to include a `BillingEnabled` property, preventing billing for admin roles.
- Enhanced the `CreateRoleCommand` and `UpdateRoleCommand` to accept billing parameters, ensuring proper handling during role creation and updates.
- Added new endpoints for billing management and integrated billing checks into VPN config creation to enforce payment requirements.
- Updated related services, models, and tests to support the new billing features, ensuring comprehensive coverage and functionality.
- Enhanced documentation to reflect the new billing processes and role management changes.
This commit is contained in:
Leonid Pershin
2026-07-19 01:38:16 +03:00
parent b980dc6cef
commit b2ae358250
106 changed files with 6018 additions and 66 deletions
@@ -0,0 +1,175 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.BackgroundJobs;
/// <summary>
/// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active →
/// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня
/// до истечения. Пользователь с заявкой на оплату в AwaitingConfirmation не трогается вообще — пока
/// админ не подтвердит/отклонит, приостановка не наступает (не по вине пользователя, что админ не
/// успел проверить оплату).
/// </summary>
public sealed class BillingService(
IServiceScopeFactory scopeFactory,
ILogger<BillingService> logger
) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromHours(1);
private static readonly TimeSpan WarningWindow = TimeSpan.FromDays(3);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval);
do
{
try
{
await RunAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Billing cycle failed");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task RunAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var identityService = scope.ServiceProvider.GetRequiredService<IIdentityService>();
var gateway = scope.ServiceProvider.GetRequiredService<IXuiPanelGateway>();
var notifier = scope.ServiceProvider.GetRequiredService<IRealtimeNotifier>();
var telegramNotifier = scope.ServiceProvider.GetRequiredService<ITelegramNotifier>();
var billingUsers = await identityService.ListBillingUsersAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var user in billingUsers)
{
// Заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/отклонит (см.
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler).
var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync(
r => r.UserId == user.UserId && r.Status == PaymentRequestStatus.AwaitingConfirmation,
cancellationToken
);
if (hasAwaitingConfirmation)
continue;
if (user.PaidUntil is not { } paidUntil || paidUntil <= now)
{
// Идемпотентно гасим конфиги на каждом тике (самовосстановление после временной
// недоступности ноды), но уведомление/аудит/флаг — только один раз, при первом обнаружении.
await DisableActiveConfigsAsync(user.UserId, dbContext, gateway, notifier, cancellationToken);
if (!user.Suspended)
{
await identityService.SuspendBillingAsync(user.UserId, cancellationToken);
dbContext.AuditLogs.Add(
AuditLog.Create(
actorId: null,
"BillingSuspended",
"User",
user.UserId.ToString(),
metadata: null,
AuditSource.System
)
);
await telegramNotifier.NotifyUserAsync(
user.UserId,
"⛔ Оплата подписки истекла — конфиги приостановлены. Продлите в разделе «Оплата», чтобы восстановить доступ.",
"/billing",
cancellationToken
);
}
continue;
}
if (user.Suspended)
continue;
if (paidUntil - now <= WarningWindow && user.LastWarnedForPaidUntil != paidUntil)
{
await telegramNotifier.NotifyUserAsync(
user.UserId,
$"⚠️ Оплата подписки истекает {paidUntil:dd.MM.yyyy}. Продлите в разделе «Оплата», иначе конфиги будут приостановлены.",
"/billing",
cancellationToken
);
await identityService.MarkBillingWarningSentAsync(user.UserId, paidUntil, cancellationToken);
}
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task DisableActiveConfigsAsync(
Guid userId,
AppDbContext dbContext,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
CancellationToken cancellationToken
)
{
var configs = await dbContext
.VpnConfigs.Where(c => c.UserId == userId && c.Status == ConfigStatus.Active)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null)
{
var updateResult = await gateway.UpdateClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
config.Label ?? config.ClientEmail,
enable: false,
cancellationToken
);
if (!updateResult.IsSuccess)
{
// Нода недоступна — конфиг остаётся Active, подхватится следующим циклом.
logger.LogWarning(
"Failed to disable client for config {ConfigId} on node {NodeId} while suspending user {UserId} for non-payment: {Error}",
config.Id,
node.Id,
userId,
updateResult.Error
);
continue;
}
}
config.Suspend();
await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
}
}
}
@@ -154,6 +154,7 @@ public static class DependencyInjection
services.AddHostedService<TrafficSyncService>();
services.AddHostedService<NodeHealthCheckService>();
services.AddHostedService<TrafficRetentionService>();
services.AddHostedService<BillingService>();
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
@@ -18,6 +18,10 @@ public class AppRole : IdentityRole<Guid>
public bool IsSystem { get; set; }
/// <summary>Включает биллинг для пользователей с этой ролью (недоступно для роли admin — см.
/// RoleService.UpdateRoleAsync).</summary>
public bool BillingEnabled { get; set; }
public AppRole() { }
public AppRole(string name)
@@ -24,4 +24,15 @@ public class AppUser : IdentityUser<Guid>
public string? TelegramUsername { get; set; }
public DateTimeOffset? TelegramLinkedAt { get; set; }
/// <summary>Оплачено до этой даты (только для billing-ролей); null — ещё ни разу не выставлялся.
/// Продлевается подтверждённой PaymentRequest, см. ConfirmPaymentRequestCommandHandler.</summary>
public DateTimeOffset? BillingPaidUntil { get; set; }
/// <summary>Конфиги приостановлены за неуплату (см. BillingService). Отдельно от IsBlocked.</summary>
public bool BillingSuspended { get; set; }
/// <summary>Для какого BillingPaidUntil уже отправлено предупреждение «истекает через N дней» —
/// не даёт слать его повторно на каждый тик джобы, пока PaidUntil не изменится.</summary>
public DateTimeOffset? BillingLastWarnedForPaidUntil { get; set; }
}
@@ -91,7 +91,10 @@ internal sealed class IdentityService(
user.IsBlocked,
role.MaxConfigs,
role.MaxIpLimit,
user.SubscriptionToken
user.SubscriptionToken,
role.BillingEnabled,
user.BillingPaidUntil,
user.BillingSuspended
);
}
@@ -376,6 +379,80 @@ internal sealed class IdentityService(
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<BillingUserDto>> ListBillingUsersAsync(
CancellationToken cancellationToken
)
{
var billingRoleNames = await roleManager
.Roles.AsNoTracking()
.Where(r => r.BillingEnabled)
.Select(r => r.Name!)
.ToListAsync(cancellationToken);
if (billingRoleNames.Count == 0)
return [];
var users = new List<BillingUserDto>();
foreach (var roleName in billingRoleNames)
{
var usersInRole = await userManager.GetUsersInRoleAsync(roleName);
users.AddRange(
usersInRole
.Where(u => !u.IsBlocked)
.Select(u => new BillingUserDto(
u.Id,
u.BillingPaidUntil,
u.BillingSuspended,
u.BillingLastWarnedForPaidUntil
))
);
}
return users;
}
public async Task<Result> SuspendBillingAsync(Guid userId, CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
user.BillingSuspended = true;
await userManager.UpdateAsync(user);
return Result.Success();
}
public async Task<Result> ExtendBillingPaidUntilAsync(
Guid userId,
DateTimeOffset paidUntil,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
user.BillingPaidUntil = paidUntil;
user.BillingSuspended = false;
user.BillingLastWarnedForPaidUntil = null;
await userManager.UpdateAsync(user);
return Result.Success();
}
public async Task<Result> MarkBillingWarningSentAsync(
Guid userId,
DateTimeOffset paidUntil,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
return Result.Failure(AuthErrors.Unauthorized);
user.BillingLastWarnedForPaidUntil = paidUntil;
await userManager.UpdateAsync(user);
return Result.Success();
}
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
{
var roles = await userManager.GetRolesAsync(user);
@@ -4,29 +4,36 @@ using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Infrastructure.Identity;
internal sealed class RoleService(
RoleManager<AppRole> roleManager,
UserManager<AppUser> userManager
UserManager<AppUser> userManager,
IAppDbContext dbContext
) : IRoleService
{
public async Task<Result<RoleDto>> CreateRoleAsync(
string name,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
)
{
if (await roleManager.RoleExistsAsync(name))
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
if (billingEnabled && name.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase))
return Result.Failure<RoleDto>(RoleErrors.BillingNotAllowedForAdmin);
var role = new AppRole(name)
{
MaxConfigs = maxConfigs,
MaxIpLimit = maxIpLimit,
IsSystem = false,
BillingEnabled = billingEnabled,
};
var result = await roleManager.CreateAsync(role);
if (!result.Succeeded)
@@ -46,6 +53,7 @@ internal sealed class RoleService(
Guid roleId,
int maxConfigs,
int maxIpLimit,
bool billingEnabled,
CancellationToken cancellationToken
)
{
@@ -53,13 +61,50 @@ internal sealed class RoleService(
if (role is null)
return Result.Failure<RoleDto>(RoleErrors.NotFound);
if (
billingEnabled
&& role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase)
)
return Result.Failure<RoleDto>(RoleErrors.BillingNotAllowedForAdmin);
var billingJustEnabled = billingEnabled && !role.BillingEnabled;
role.MaxConfigs = maxConfigs;
role.MaxIpLimit = maxIpLimit;
role.BillingEnabled = billingEnabled;
await roleManager.UpdateAsync(role);
if (billingJustEnabled)
{
var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!);
await InitializeBillingGraceAsync(usersInRole, cancellationToken);
}
return Result.Success(ToDto(role));
}
/// <summary>Первая выдача грейс-периода — только пользователям, у которых оплата ещё ни разу не
/// выставлялась (BillingPaidUntil == null), чтобы не сбрасывать уже приостановленным/оплатившим.</summary>
private async Task InitializeBillingGraceAsync(
IEnumerable<AppUser> users,
CancellationToken cancellationToken
)
{
var usersNeedingGrace = users.Where(u => u.BillingPaidUntil is null).ToList();
if (usersNeedingGrace.Count == 0)
return;
var settings = await dbContext.BillingSettings.FirstOrDefaultAsync(cancellationToken);
var graceDays = settings?.GraceDays ?? BillingSettings.DefaultGraceDays;
var paidUntil = DateTimeOffset.UtcNow.AddDays(graceDays);
foreach (var user in usersNeedingGrace)
{
user.BillingPaidUntil = paidUntil;
await userManager.UpdateAsync(user);
}
}
public async Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken)
{
var role = await roleManager.FindByIdAsync(roleId.ToString());
@@ -81,7 +126,14 @@ internal sealed class RoleService(
{
return await roleManager
.Roles.OrderBy(r => r.Name)
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem))
.Select(r => new RoleDto(
r.Id,
r.Name!,
r.MaxConfigs,
r.MaxIpLimit,
r.IsSystem,
r.BillingEnabled
))
.ToListAsync(cancellationToken);
}
@@ -114,9 +166,13 @@ internal sealed class RoleService(
await userManager.RemoveFromRolesAsync(user, currentRoles);
await userManager.AddToRoleAsync(user, role.Name!);
if (role.BillingEnabled)
await InitializeBillingGraceAsync([user], cancellationToken);
return Result.Success();
}
private static RoleDto ToDto(AppRole role) =>
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem);
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem, role.BillingEnabled);
}
@@ -4,6 +4,7 @@ using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Apps;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
@@ -58,6 +59,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
public DbSet<BillingSettings> BillingSettings => Set<BillingSettings>();
public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Infrastructure.Persistence.Configurations;
public class BillingSettingsConfiguration : IEntityTypeConfiguration<BillingSettings>
{
public void Configure(EntityTypeBuilder<BillingSettings> builder)
{
builder.ToTable("BillingSettings");
builder.HasKey(x => x.Id);
builder.Property(x => x.RequisitesText).IsRequired().HasMaxLength(4000);
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Infrastructure.Persistence.Configurations;
public class PaymentRequestConfiguration : IEntityTypeConfiguration<PaymentRequest>
{
public void Configure(EntityTypeBuilder<PaymentRequest> builder)
{
builder.ToTable("PaymentRequests");
builder.HasKey(x => x.Id);
builder.Property(x => x.Period).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
builder.Property(x => x.RejectionReason).HasMaxLength(500);
builder.HasIndex(x => new { x.UserId, x.Status });
}
}
@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddBilling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "BillingLastWarnedForPaidUntil",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "BillingPaidUntil",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "BillingSuspended",
table: "AspNetUsers",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "BillingEnabled",
table: "AspNetRoles",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "BillingSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
RequisitesText = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
GraceDays = table.Column<int>(type: "integer", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BillingSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PaymentRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Period = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
AmountSnapshot = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
DecidedBy = table.Column<Guid>(type: "uuid", nullable: true),
DecidedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
RejectionReason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PaymentRequests", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PaymentRequests_UserId_Status",
table: "PaymentRequests",
columns: new[] { "UserId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BillingSettings");
migrationBuilder.DropTable(
name: "PaymentRequests");
migrationBuilder.DropColumn(
name: "BillingLastWarnedForPaidUntil",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "BillingPaidUntil",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "BillingSuspended",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "BillingEnabled",
table: "AspNetRoles");
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddBillingDefaultForNewRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DefaultBillingEnabledForNewRoles",
table: "BillingSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DefaultBillingEnabledForNewRoles",
table: "BillingSettings");
}
}
}
@@ -250,6 +250,73 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("DefaultBillingEnabledForNewRoles")
.HasColumnType("boolean");
b.Property<int>("GraceDays")
.HasColumnType("integer");
b.Property<string>("RequisitesText")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("BillingSettings", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AmountSnapshot")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DecidedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DecidedBy")
.HasColumnType("uuid");
b.Property<string>("Period")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("RejectionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "Status");
b.ToTable("PaymentRequests", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
{
b.Property<long>("Id")
@@ -713,6 +780,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("BillingEnabled")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
@@ -758,6 +828,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
b.Property<Guid?>("ActivatedBy")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("BillingLastWarnedForPaidUntil")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("BillingPaidUntil")
.HasColumnType("timestamp with time zone");
b.Property<bool>("BillingSuspended")
.HasColumnType("boolean");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");