diff --git a/CLAUDE.md b/CLAUDE.md index 621f26b..49b2cfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,9 @@ [`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и хранит проекцию домена в PostgreSQL. Живые обновления — SignalR. Поставка — **единый Docker-образ** (фронт+бек+бот) + PostgreSQL в compose. -> Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Осознанно не реализовано: -> тарифы, лимиты трафика/срока на конфиг, полное самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md). +> Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Есть опциональный биллинг +> (подписка по сроку, per-роль). Осознанно не реализовано: лимиты трафика на конфиг, полное +> самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md). ## Документация (single source of truth) @@ -92,6 +93,12 @@ админский путь дополнительно пишет `AuditLog` (`UserDeleted`) и шлёт Telegram-DM. - **Аудит**: значимые действия (активация/блок/роль/отзыв/ноды/инбаунды/удаление) — `AuditLog` (append-only, источник Web/Telegram/System). +- **Биллинг** (`AppRole.BillingEnabled`, недоступен для `admin`): пользователь оформляет + `PaymentRequest` на 3/6/12 мес (сумма — по `PricingSettings`, заморожена на заявке), админ + подтверждает/отклоняет на сайте или в Telegram (`pay:*`). Пока заявка `AwaitingConfirmation` — + конфиги не гасятся, даже если срок истёк (не по вине пользователя, что админ не успел). Просрочка + без заявки → `VpnConfig.Suspend()` (статус `Expired`, отдельно от `Disable()`/блокировки админом) — + см. [domain-model.md](docs/domain-model.md#billing--подписка-по-сроку). - **Ротация конфига** (`Rotate()`) — новый UUID/ссылка, квоту не тратит. **Бот read-only** по конфигам. - **Подписка**: агрегированная (`AppUser.SubscriptionToken`) + по конфигу. API без версионирования (`/api`, без `v1`); подписка отдаёт `Subscription-Userinfo`. @@ -168,9 +175,9 @@ docker compose up -d # api + postgres (+ web) ## Ключевые решения См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — собственный -диспетчер (не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; тарифы `Plan` -не реализованы; i18n — RU+EN (react-i18next); Telegram — long polling, только привязка (не signup); -история трафика — простая таблица + TTL; логирование — Serilog. +диспетчер (не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; биллинг +опционален per-роль (недоступен для `admin`); i18n — RU+EN (react-i18next); Telegram — long polling, +только привязка (не signup); история трафика — простая таблица + TTL; логирование — Serilog. ## Рабочие принципы diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs new file mode 100644 index 0000000..2e63a00 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs @@ -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(); + admin.MapPut("/settings", UpdateSettings).Produces(); + admin + .MapGet("/requests", ListRequests) + .Produces>(); + 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 GetSettings(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetBillingSettingsQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateSettings( + UpdateBillingSettingsCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 ConfirmRequest( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ConfirmPaymentRequestCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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); diff --git a/backend/src/PnvPanel.Api/Endpoints/BillingEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/BillingEndpoints.cs new file mode 100644 index 0000000..814c6e5 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/BillingEndpoints.cs @@ -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(); + group.MapPost("/requests", CreateRequest).Produces(); + 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 GetStatus(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetMyBillingStatusQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateRequest( + CreatePaymentRequestBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new CreatePaymentRequestCommand(body.Period), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task CancelRequest( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new CancelPaymentRequestCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task MarkPaid( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new MarkPaymentSentCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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); diff --git a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs index f36f2c0..130a4f8 100644 --- a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs @@ -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); diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index c823a2a..983b709 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -192,6 +192,8 @@ app.MapAdminAppEndpoints(); app.MapAdminNewsEndpoints(); app.MapAdminInstructionEndpoints(); app.MapAdminPricingEndpoints(); +app.MapBillingEndpoints(); +app.MapAdminBillingEndpoints(); app.MapSupportEndpoints(); app.MapAdminSupportEndpoints(); app.MapAdminMaintenanceEndpoints(); diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index e34aab0..99560be 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -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)) diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs index f322e10..ad0543f 100644 --- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -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 = + $"💰 {Escape(userName)} заявляет об оплате за {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, diff --git a/backend/src/PnvPanel.Application/Admin/Billing/AdminPaymentRequestDto.cs b/backend/src/PnvPanel.Application/Admin/Billing/AdminPaymentRequestDto.cs new file mode 100644 index 0000000..ebb9fa9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/AdminPaymentRequestDto.cs @@ -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 +); diff --git a/backend/src/PnvPanel.Application/Admin/Billing/BillingSettingsDto.cs b/backend/src/PnvPanel.Application/Admin/Billing/BillingSettingsDto.cs new file mode 100644 index 0000000..26904ff --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/BillingSettingsDto.cs @@ -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); +} diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommand.cs b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommand.cs new file mode 100644 index 0000000..49bc99d --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommand.cs @@ -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; diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs new file mode 100644 index 0000000..73ef6f6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs @@ -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; + +/// +/// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги, +/// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя — +/// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка). +/// +public sealed class ConfirmPaymentRequestCommandHandler( + IAppDbContext dbContext, + IIdentityService identityService, + IXuiPanelGateway gateway, + IRealtimeNotifier notifier, + ITelegramNotifier telegramNotifier, + ICurrentUser currentUser, + ILogger logger +) : ICommandHandler +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Billing/GetBillingSettingsQuery.cs b/backend/src/PnvPanel.Application/Admin/Billing/GetBillingSettingsQuery.cs new file mode 100644 index 0000000..d155c2d --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/GetBillingSettingsQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Billing; + +public sealed record GetBillingSettingsQuery : IQuery>; diff --git a/backend/src/PnvPanel.Application/Admin/Billing/GetBillingSettingsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/GetBillingSettingsQueryHandler.cs new file mode 100644 index 0000000..a675225 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/GetBillingSettingsQueryHandler.cs @@ -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> +{ + public async Task> 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) + ); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQuery.cs b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQuery.cs new file mode 100644 index 0000000..25ff9bd --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQuery.cs @@ -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>>; diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs new file mode 100644 index 0000000..4106c3c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/ListPaymentRequestsQueryHandler.cs @@ -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>> +{ + public async Task>> 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(items, page1.Total, page1.Page, page1.PageSize) + ); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommand.cs b/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommand.cs new file mode 100644 index 0000000..8c961ac --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommand.cs @@ -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; diff --git a/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs new file mode 100644 index 0000000..46a2643 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/RejectPaymentRequestCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommand.cs b/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommand.cs new file mode 100644 index 0000000..5569ebe --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommand.cs @@ -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>; diff --git a/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommandHandler.cs new file mode 100644 index 0000000..cab83bf --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommandHandler.cs @@ -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> +{ + public async Task> 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)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommandValidator.cs new file mode 100644 index 0000000..9899d15 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/UpdateBillingSettingsCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Billing; + +public sealed class UpdateBillingSettingsCommandValidator + : AbstractValidator +{ + public UpdateBillingSettingsCommandValidator() + { + RuleFor(x => x.RequisitesText).NotEmpty().MaximumLength(4000); + RuleFor(x => x.GraceDays).GreaterThanOrEqualTo(0).LessThanOrEqualTo(365); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs index 3fec0d8..e83be89 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs @@ -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>; +public sealed record CreateRoleCommand( + string Name, + int MaxConfigs, + int MaxIpLimit, + bool BillingEnabled +) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs index 8d6efff..7aa5707 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs @@ -15,6 +15,7 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService) command.Name, command.MaxConfigs, command.MaxIpLimit, + command.BillingEnabled, cancellationToken ); } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs b/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs index aee355c..6da6648 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs @@ -21,4 +21,8 @@ public static class RoleErrors "Roles.CannotRemoveLastAdmin", "Нельзя снять роль admin с последнего администратора." ); + public static readonly Error BillingNotAllowedForAdmin = Error.Validation( + "Roles.BillingNotAllowedForAdmin", + "Биллинг нельзя включить для роли admin." + ); } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs index de07ab2..3ab13c3 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs @@ -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>; +public sealed record UpdateRoleCommand( + Guid RoleId, + int MaxConfigs, + int MaxIpLimit, + bool BillingEnabled +) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs index a2dc00b..e801c51 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs @@ -15,6 +15,7 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService) command.RoleId, command.MaxConfigs, command.MaxIpLimit, + command.BillingEnabled, cancellationToken ); } diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs index ef5d00f..6c610da 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs @@ -49,6 +49,7 @@ public sealed class ApproveRoleRequestCommandHandler( ticket.ProposedRoleName!, ticket.ProposedMaxConfigs!.Value, ticket.ProposedMaxIpLimit!.Value, + billingEnabled: false, cancellationToken ); if (!createResult.IsSuccess) diff --git a/backend/src/PnvPanel.Application/Billing/BillingErrors.cs b/backend/src/PnvPanel.Application/Billing/BillingErrors.cs new file mode 100644 index 0000000..cafad41 --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/BillingErrors.cs @@ -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", + "Заявка уже обработана." + ); +} diff --git a/backend/src/PnvPanel.Application/Billing/BillingStatusDto.cs b/backend/src/PnvPanel.Application/Billing/BillingStatusDto.cs new file mode 100644 index 0000000..19afb43 --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/BillingStatusDto.cs @@ -0,0 +1,9 @@ +namespace PnvPanel.Application.Billing; + +public sealed record BillingStatusDto( + bool BillingEnabled, + DateTimeOffset? PaidUntil, + bool Suspended, + string RequisitesText, + PaymentRequestDto? ActiveRequest +); diff --git a/backend/src/PnvPanel.Application/Billing/CancelPaymentRequest/CancelPaymentRequestCommand.cs b/backend/src/PnvPanel.Application/Billing/CancelPaymentRequest/CancelPaymentRequestCommand.cs new file mode 100644 index 0000000..4ab22cc --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/CancelPaymentRequest/CancelPaymentRequestCommand.cs @@ -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, + IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Billing/CancelPaymentRequest/CancelPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/CancelPaymentRequest/CancelPaymentRequestCommandHandler.cs new file mode 100644 index 0000000..1c8562e --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/CancelPaymentRequest/CancelPaymentRequestCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommand.cs b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommand.cs new file mode 100644 index 0000000..6b17499 --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommand.cs @@ -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>, + IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs new file mode 100644 index 0000000..e8f812c --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs @@ -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> +{ + public async Task> Handle( + CreatePaymentRequestCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + if (!profile.BillingEnabled) + return Result.Failure(BillingErrors.NotEnabled); + + if (profile.MaxConfigs == RoleQuota.Unlimited) + return Result.Failure(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(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(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)); + } +} diff --git a/backend/src/PnvPanel.Application/Billing/GetMyBillingStatus/GetMyBillingStatusQuery.cs b/backend/src/PnvPanel.Application/Billing/GetMyBillingStatus/GetMyBillingStatusQuery.cs new file mode 100644 index 0000000..5966417 --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/GetMyBillingStatus/GetMyBillingStatusQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Billing.GetMyBillingStatus; + +public sealed record GetMyBillingStatusQuery : IQuery>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandler.cs b/backend/src/PnvPanel.Application/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandler.cs new file mode 100644 index 0000000..13f25ca --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandler.cs @@ -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> +{ + public async Task> Handle( + GetMyBillingStatusQuery query, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(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) + ) + ); + } +} diff --git a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommand.cs b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommand.cs new file mode 100644 index 0000000..58afc2a --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommand.cs @@ -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, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs new file mode 100644 index 0000000..9a8b9b7 --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Billing/PaymentRequestDto.cs b/backend/src/PnvPanel.Application/Billing/PaymentRequestDto.cs new file mode 100644 index 0000000..c3b603c --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/PaymentRequestDto.cs @@ -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); +} diff --git a/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommand.cs b/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommand.cs new file mode 100644 index 0000000..a3dc5b3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommand.cs @@ -0,0 +1,10 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Billing.SendRequisitesToTelegram; + +/// Дублирует реквизиты активной заявки в Telegram пользователю — для удобства (скопировать +/// с телефона), сам статус заявки не меняет. +public sealed record SendRequisitesToTelegramCommand(Guid RequestId) + : ICommand, + IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs new file mode 100644 index 0000000..6d2bcfa --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs @@ -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 +{ + public async Task 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(), + }; +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs index 408ea78..879d44e 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -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 { get; } + DbSet BillingSettings { get; } + + DbSet PaymentRequests { get; } + /// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler). DatabaseFacade Database { get; } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs index 85f1795..a571406 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -13,7 +13,19 @@ public sealed record CurrentUserProfile( bool IsBlocked, int MaxConfigs, int MaxIpLimit, - string SubscriptionToken + string SubscriptionToken, + bool BillingEnabled, + DateTimeOffset? BillingPaidUntil, + bool BillingSuspended +); + +/// Срез биллинг-полей пользователя — для фоновой джобы приостановки (см. BillingService), +/// не требует полного CurrentUserProfile (роль/квоты там не нужны). +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 ); + + /// Пользователи с billing-ролью (role.BillingEnabled), не заблокированные — обход для + /// BillingService (приостановка за неуплату / предупреждения). + Task> ListBillingUsersAsync(CancellationToken cancellationToken); + + /// Гасит конфиги за неуплату на уровне пользователя (BillingSuspended=true) — сами + /// конфиги гасит вызывающая сторона (см. BillingService, по образцу BlockUserCommandHandler). + Task SuspendBillingAsync(Guid userId, CancellationToken cancellationToken); + + /// Продлевает оплаченный период, снимает приостановку и сбрасывает флаг "предупреждение + /// отправлено" (новый срок ещё не близко к истечению). Используется и при подтверждении оплаты, + /// и при первичной выдаче грейс-периода. + Task ExtendBillingPaidUntilAsync( + Guid userId, + DateTimeOffset paidUntil, + CancellationToken cancellationToken + ); + + Task MarkBillingWarningSentAsync( + Guid userId, + DateTimeOffset paidUntil, + CancellationToken cancellationToken + ); } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs index 272608b..0362e05 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs @@ -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 ); diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs index 8b30e00..ce00b2b 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -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 ); + + /// Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается + /// полностью в Telegram (аналогично заявке на роль). + Task NotifyAdminsPaymentRequestedAsync( + Guid requestId, + string userName, + PaymentPeriod period, + int amount, + CancellationToken cancellationToken + ); } diff --git a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs index d3f5a7c..1005a02 100644 --- a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs +++ b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs @@ -30,4 +30,9 @@ public static class ConfigErrors "Configs.NodeUnavailable", "Сервер временно недоступен. Попробуйте повторить операцию позже." ); + + public static readonly Error BillingRequired = Error.Forbidden( + "Configs.BillingRequired", + "Требуется оплата подписки — оформите заявку на оплату в разделе «Оплата»." + ); } diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs index aaf2703..c762ba7 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -36,6 +36,13 @@ public sealed class CreateVpnConfigCommandHandler( if (!inbound.AllowedRoleIds.Contains(profile.RoleId)) return Result.Failure(ConfigErrors.InboundNotAllowedForRole); + // Не даём обойти приостановку за неуплату созданием нового конфига — см. BillingService. + if ( + profile.BillingEnabled + && (profile.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow) + ) + return Result.Failure(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(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, diff --git a/backend/src/PnvPanel.Domain/Billing/BillingSettings.cs b/backend/src/PnvPanel.Domain/Billing/BillingSettings.cs new file mode 100644 index 0000000..9e6d35e --- /dev/null +++ b/backend/src/PnvPanel.Domain/Billing/BillingSettings.cs @@ -0,0 +1,45 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Billing; + +/// +/// Единственная строка в таблице — глобальные настройки биллинга, редактируются админом. +/// +public sealed class BillingSettings : Entity +{ + /// Реквизиты для оплаты (карта/крипто-адрес/СБП и т.д.) — произвольный текст. + public string RequisitesText { get; private set; } = string.Empty; + + /// Дней грейс-периода для пользователя, впервые попавшего под биллинг (назначена + /// billing-роль, или роли включили BillingEnabled, а PaidUntil ещё ни разу не выставлялся). + public int GraceDays { get; private set; } = DefaultGraceDays; + + public const int DefaultGraceDays = 7; + + /// Начальное состояние чекбокса "Включить биллинг" в диалоге создания новой роли — + /// чистое удобство админа, не влияет на уже существующие роли и не гейтит ничего само по себе. + 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; + } +} diff --git a/backend/src/PnvPanel.Domain/Billing/PaymentPeriod.cs b/backend/src/PnvPanel.Domain/Billing/PaymentPeriod.cs new file mode 100644 index 0000000..e9c8206 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Billing/PaymentPeriod.cs @@ -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)), + }; +} diff --git a/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs b/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs new file mode 100644 index 0000000..7181225 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs @@ -0,0 +1,81 @@ +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Domain.Billing; + +/// +/// Заявка пользователя на оплату подписки за период (квартал/полгода/год). Сумма замораживается на +/// момент создания (по действовавшей на тот момент ставке PricingSettings) — последующее изменение +/// прайса админом не меняет уже созданные заявки. Не более одной активной (AwaitingPayment/ +/// AwaitingConfirmation) заявки на пользователя — инвариант проверяется на уровне Application. +/// +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, + }; + } + + /// Пользователь нажал «Я оплатил» — уходит на подтверждение админом. + public void MarkPaymentSent() + { + if (Status != PaymentRequestStatus.AwaitingPayment) + throw new DomainException($"Нельзя отметить оплаченной заявку в статусе {Status}."); + + Status = PaymentRequestStatus.AwaitingConfirmation; + } + + /// Пользователь передумал — можно только пока не заявлено «Я оплатил» (после этого + /// решение уже за админом, отменять заявку из-под него нельзя). + public void Cancel() + { + if (Status != PaymentRequestStatus.AwaitingPayment) + throw new DomainException($"Нельзя отменить заявку в статусе {Status}."); + + Status = PaymentRequestStatus.Cancelled; + } + + /// Допустимо и из AwaitingPayment (админ увидел оплату раньше, чем юзер нажал кнопку), + /// и из AwaitingConfirmation (обычный путь). + 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})."); + } +} diff --git a/backend/src/PnvPanel.Domain/Billing/PaymentRequestStatus.cs b/backend/src/PnvPanel.Domain/Billing/PaymentRequestStatus.cs new file mode 100644 index 0000000..610c839 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Billing/PaymentRequestStatus.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Domain.Billing; + +public enum PaymentRequestStatus +{ + AwaitingPayment, + AwaitingConfirmation, + Confirmed, + Rejected, + Cancelled, +} diff --git a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs index 4b684b7..c729465 100644 --- a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs +++ b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs @@ -88,6 +88,26 @@ public sealed class VpnConfig : Entity Status = ConfigStatus.Active; } + /// Приостановка за неуплату (биллинг) — отдельный статус от Disable/Enable (блокировка + /// админом), чтобы разблокировка/оплата не задевали друг друга по ошибке. + public void Suspend() + { + if (Status == ConfigStatus.Active) + Status = ConfigStatus.Expired; + } + + /// Возврат из приостановки за неуплату — только то, что было погашено именно ею. + public void Resume() + { + if (Status == ConfigStatus.Expired) + Status = ConfigStatus.Active; + } + + /// Денормализация даты окончания оплаченного периода пользователя (см. AppUser.BillingPaidUntil) + /// на конфиг — используется в Subscription-Userinfo для VPN-клиента (см. SubscriptionAssembler). + /// Null для ролей без биллинга. + public void SetBillingExpiry(DateTimeOffset? expiresAt) => ExpiresAt = expiresAt; + private void EnsureActive(string action) { if (Status != ConfigStatus.Active) diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs new file mode 100644 index 0000000..d850f2b --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs @@ -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; + +/// +/// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active → +/// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня +/// до истечения. Пользователь с заявкой на оплату в AwaitingConfirmation не трогается вообще — пока +/// админ не подтвердит/отклонит, приостановка не наступает (не по вине пользователя, что админ не +/// успел проверить оплату). +/// +public sealed class BillingService( + IServiceScopeFactory scopeFactory, + ILogger 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(); + var identityService = scope.ServiceProvider.GetRequiredService(); + var gateway = scope.ServiceProvider.GetRequiredService(); + var notifier = scope.ServiceProvider.GetRequiredService(); + var telegramNotifier = scope.ServiceProvider.GetRequiredService(); + + 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 + ); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs index a95b2d5..6577a93 100644 --- a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -154,6 +154,7 @@ public static class DependencyInjection services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); services.Configure(configuration.GetSection(TelegramOptions.SectionName)); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs index 2515534..e423937 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs @@ -18,6 +18,10 @@ public class AppRole : IdentityRole public bool IsSystem { get; set; } + /// Включает биллинг для пользователей с этой ролью (недоступно для роли admin — см. + /// RoleService.UpdateRoleAsync). + public bool BillingEnabled { get; set; } + public AppRole() { } public AppRole(string name) diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs index faf597e..eef1616 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs @@ -24,4 +24,15 @@ public class AppUser : IdentityUser public string? TelegramUsername { get; set; } public DateTimeOffset? TelegramLinkedAt { get; set; } + + /// Оплачено до этой даты (только для billing-ролей); null — ещё ни разу не выставлялся. + /// Продлевается подтверждённой PaymentRequest, см. ConfirmPaymentRequestCommandHandler. + public DateTimeOffset? BillingPaidUntil { get; set; } + + /// Конфиги приостановлены за неуплату (см. BillingService). Отдельно от IsBlocked. + public bool BillingSuspended { get; set; } + + /// Для какого BillingPaidUntil уже отправлено предупреждение «истекает через N дней» — + /// не даёт слать его повторно на каждый тик джобы, пока PaidUntil не изменится. + public DateTimeOffset? BillingLastWarnedForPaidUntil { get; set; } } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs index e106223..3cec77e 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -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> 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(); + 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 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 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 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 GetPrimaryRoleNameAsync(AppUser user) { var roles = await userManager.GetRolesAsync(user); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs index fb5e7f4..a0edf2b 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs @@ -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 roleManager, - UserManager userManager + UserManager userManager, + IAppDbContext dbContext ) : IRoleService { public async Task> CreateRoleAsync( string name, int maxConfigs, int maxIpLimit, + bool billingEnabled, CancellationToken cancellationToken ) { if (await roleManager.RoleExistsAsync(name)) return Result.Failure(RoleErrors.DuplicateName); + if (billingEnabled && name.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase)) + return Result.Failure(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(RoleErrors.NotFound); + if ( + billingEnabled + && role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase) + ) + return Result.Failure(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)); } + /// Первая выдача грейс-периода — только пользователям, у которых оплата ещё ни разу не + /// выставлялась (BillingPaidUntil == null), чтобы не сбрасывать уже приостановленным/оплатившим. + private async Task InitializeBillingGraceAsync( + IEnumerable 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 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); } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs index 2a27411..f8a62e7 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -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 options) public DbSet PricingSettings => Set(); + public DbSet BillingSettings => Set(); + + public DbSet PaymentRequests => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/BillingSettingsConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/BillingSettingsConfiguration.cs new file mode 100644 index 0000000..02d3331 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/BillingSettingsConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("BillingSettings"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.RequisitesText).IsRequired().HasMaxLength(4000); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PaymentRequestConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PaymentRequestConfiguration.cs new file mode 100644 index 0000000..fedb4fe --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PaymentRequestConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PaymentRequests"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Period).HasConversion().HasMaxLength(32); + builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + builder.Property(x => x.RejectionReason).HasMaxLength(500); + + builder.HasIndex(x => new { x.UserId, x.Status }); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718215422_AddBilling.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718215422_AddBilling.Designer.cs new file mode 100644 index 0000000..3786fef --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718215422_AddBilling.Designer.cs @@ -0,0 +1,1040 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260718215422_AddBilling")] + partial class AddBilling + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingEnabled") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718215422_AddBilling.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718215422_AddBilling.cs new file mode 100644 index 0000000..97c0805 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718215422_AddBilling.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddBilling : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BillingLastWarnedForPaidUntil", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "BillingPaidUntil", + table: "AspNetUsers", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "BillingSuspended", + table: "AspNetUsers", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "BillingEnabled", + table: "AspNetRoles", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "BillingSettings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RequisitesText = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + GraceDays = table.Column(type: "integer", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Period = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + AmountSnapshot = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + DecidedBy = table.Column(type: "uuid", nullable: true), + DecidedAt = table.Column(type: "timestamp with time zone", nullable: true), + RejectionReason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(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" }); + } + + /// + 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"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718223402_AddBillingDefaultForNewRoles.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718223402_AddBillingDefaultForNewRoles.Designer.cs new file mode 100644 index 0000000..834a591 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718223402_AddBillingDefaultForNewRoles.Designer.cs @@ -0,0 +1,1043 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260718223402_AddBillingDefaultForNewRoles")] + partial class AddBillingDefaultForNewRoles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultBillingEnabledForNewRoles") + .HasColumnType("boolean"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingEnabled") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718223402_AddBillingDefaultForNewRoles.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718223402_AddBillingDefaultForNewRoles.cs new file mode 100644 index 0000000..7d7389f --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718223402_AddBillingDefaultForNewRoles.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddBillingDefaultForNewRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DefaultBillingEnabledForNewRoles", + table: "BillingSettings", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DefaultBillingEnabledForNewRoles", + table: "BillingSettings"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 04cc99a..226872d 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -250,6 +250,73 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.ToTable("AuditLogs", (string)null); }); + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultBillingEnabledForNewRoles") + .HasColumnType("boolean"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => { b.Property("Id") @@ -713,6 +780,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("BillingEnabled") + .HasColumnType("boolean"); + b.Property("ConcurrencyStamp") .IsConcurrencyToken() .HasColumnType("text"); @@ -758,6 +828,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.Property("ActivatedBy") .HasColumnType("uuid"); + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + b.Property("ConcurrencyStamp") .IsConcurrencyToken() .HasColumnType("text"); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs new file mode 100644 index 0000000..3c910b1 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs @@ -0,0 +1,237 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; +using PnvPanel.Application.Admin.Billing; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Billing; + +public class ConfirmPaymentRequestCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + private readonly ILogger _logger = Substitute.For< + ILogger + >(); + + private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) => + new( + userId, + "alice", + Guid.NewGuid(), + "premium", + IsActivated: true, + IsBlocked: false, + MaxConfigs: 5, + MaxIpLimit: 3, + SubscriptionToken: "sub-token", + BillingEnabled: true, + BillingPaidUntil: paidUntil, + BillingSuspended: paidUntil is null + ); + + [Fact] + public async Task Handle_WhenNoPriorPayment_ExtendsFromNow() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); + request.MarkPaymentSent(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, paidUntil: null)); + _identityService + .ExtendBillingPaidUntilAsync(userId, Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + + var handler = new ConfirmPaymentRequestCommandHandler( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(adminId, "admin"), + _logger + ); + + var before = DateTimeOffset.UtcNow; + var result = await handler.Handle( + new ConfirmPaymentRequestCommand(request.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(PaymentRequestStatus.Confirmed, request.Status); + await _identityService + .Received(1) + .ExtendBillingPaidUntilAsync( + userId, + Arg.Is(d => d >= before.AddMonths(3).AddMinutes(-1)), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_WhenPaidUntilInFuture_ExtendsFromExistingPaidUntil() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20); + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); + request.MarkPaymentSent(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, paidUntil: existingPaidUntil)); + _identityService + .ExtendBillingPaidUntilAsync(userId, Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + + var handler = new ConfirmPaymentRequestCommandHandler( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(adminId, "admin"), + _logger + ); + + await handler.Handle(new ConfirmPaymentRequestCommand(request.Id), CancellationToken.None); + + var expected = existingPaidUntil.AddMonths(3); + await _identityService + .Received(1) + .ExtendBillingPaidUntilAsync( + userId, + Arg.Is(d => Math.Abs((d - expected).TotalSeconds) < 5), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_ResumesExpiredConfigsAndSyncsExpiry() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var node = Node.Register( + "node-1", + new Uri("https://node1.example.com"), + new NodeCredentials("admin", "protected"), + null + ); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var expiredConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null); + expiredConfig.AssignRemoteClient("ext-1"); + expiredConfig.Suspend(); + var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null); + activeConfig.AssignRemoteClient("ext-2"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(expiredConfig, activeConfig); + + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); + request.MarkPaymentSent(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, paidUntil: null)); + _identityService + .ExtendBillingPaidUntilAsync(userId, Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + _gateway + .UpdateClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + enable: true, + Arg.Any() + ) + .Returns(Result.Success()); + + var handler = new ConfirmPaymentRequestCommandHandler( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(adminId, "admin"), + _logger + ); + + var result = await handler.Handle( + new ConfirmPaymentRequestCommand(request.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Active, expiredConfig.Status); + Assert.NotNull(expiredConfig.ExpiresAt); + Assert.NotNull(activeConfig.ExpiresAt); + Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt); + await _gateway + .Received(1) + .UpdateClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + "ext-1", + VpnProtocol.Vless, + Arg.Any(), + enable: true, + Arg.Any() + ); + } + + [Fact] + public async Task Handle_WhenRequestAlreadyDecided_ReturnsNotDecidable() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); + request.Cancel(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new ConfirmPaymentRequestCommandHandler( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(adminId, "admin"), + _logger + ); + + var result = await handler.Handle( + new ConfirmPaymentRequestCommand(request.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.RequestNotDecidable, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs new file mode 100644 index 0000000..e4c5c5e --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs @@ -0,0 +1,69 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Billing; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Billing; + +public class RejectPaymentRequestCommandHandlerTests +{ + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + + [Fact] + public async Task Handle_WhenAwaitingConfirmation_RejectsAndNotifiesUser() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); + request.MarkPaymentSent(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new RejectPaymentRequestCommandHandler( + dbContext, + _telegramNotifier, + FakeCurrentUser.Authenticated(adminId, "admin") + ); + + var result = await handler.Handle( + new RejectPaymentRequestCommand(request.Id, "Платёж не найден"), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(PaymentRequestStatus.Rejected, request.Status); + Assert.Equal("Платёж не найден", request.RejectionReason); + await _telegramNotifier + .Received(1) + .NotifyUserAsync( + userId, + Arg.Is(m => m.Contains("Платёж не найден")), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_WhenRequestNotFound_ReturnsRequestNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + var handler = new RejectPaymentRequestCommandHandler( + dbContext, + _telegramNotifier, + FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin") + ); + + var result = await handler.Handle( + new RejectPaymentRequestCommand(Guid.NewGuid(), null), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.RequestNotFound, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs index 458e84a..cca0d29 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs @@ -72,8 +72,8 @@ public class FactoryResetCommandHandlerTests .Returns( new List { - new(adminRoleId, "admin", -1, -1, IsSystem: true), - new(customRoleId, "premium", 10, 5, IsSystem: false), + new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false), + new(customRoleId, "premium", 10, 5, IsSystem: false, BillingEnabled: false), } ); _roleService diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs index 2715c5d..4b94062 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs @@ -27,8 +27,8 @@ public class ApproveRoleRequestCommandHandlerTests var newRoleId = Guid.NewGuid(); _roleService - .CreateRoleAsync("premium", 10, 5, Arg.Any()) - .Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false))); + .CreateRoleAsync("premium", 10, 5, false, Arg.Any()) + .Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false, false))); _roleService .ChangeUserRoleAsync(userId, newRoleId, Arg.Any()) .Returns(Result.Success()); @@ -51,7 +51,7 @@ public class ApproveRoleRequestCommandHandlerTests Assert.Equal(TicketStatus.Resolved, ticket.Status); await _roleService .Received(1) - .CreateRoleAsync("premium", 10, 5, Arg.Any()); + .CreateRoleAsync("premium", 10, 5, false, Arg.Any()); await _roleService .Received(1) .ChangeUserRoleAsync(userId, newRoleId, Arg.Any()); @@ -100,6 +100,7 @@ public class ApproveRoleRequestCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any() ); } diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs index 70a81dc..e1a960b 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs @@ -55,7 +55,10 @@ public class GetCurrentUserQueryHandlerTests false, 3, RoleQuota.Unlimited, - "sub-token" + "sub-token", + false, + null, + false ); _identityService.GetProfileAsync(userId, Arg.Any()).Returns(profile); _identityService diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs index 7b19506..c515896 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs @@ -31,7 +31,10 @@ public class LoginCommandHandlerTests IsBlocked: false, MaxConfigs: 3, MaxIpLimit: RoleQuota.Unlimited, - SubscriptionToken: "sub-token" + SubscriptionToken: "sub-token", + BillingEnabled: false, + BillingPaidUntil: null, + BillingSuspended: false ); _identityService diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs index a69f304..0390bb9 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs @@ -30,7 +30,10 @@ public class RefreshCommandHandlerTests IsBlocked: false, MaxConfigs: 3, MaxIpLimit: RoleQuota.Unlimited, - SubscriptionToken: "sub-token" + SubscriptionToken: "sub-token", + BillingEnabled: false, + BillingPaidUntil: null, + BillingSuspended: false ); var rotated = new RotatedRefreshToken( userId, diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/CancelPaymentRequest/CancelPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/CancelPaymentRequest/CancelPaymentRequestCommandHandlerTests.cs new file mode 100644 index 0000000..f66da2c --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Billing/CancelPaymentRequest/CancelPaymentRequestCommandHandlerTests.cs @@ -0,0 +1,79 @@ +using PnvPanel.Application.Billing; +using PnvPanel.Application.Billing.CancelPaymentRequest; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using Xunit; + +namespace PnvPanel.Application.Tests.Billing.CancelPaymentRequest; + +public class CancelPaymentRequestCommandHandlerTests +{ + [Fact] + public async Task Handle_WhenAwaitingPayment_Cancels() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new CancelPaymentRequestCommandHandler( + dbContext, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CancelPaymentRequestCommand(request.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(PaymentRequestStatus.Cancelled, request.Status); + } + + [Fact] + public async Task Handle_WhenAwaitingConfirmation_ReturnsNotCancellable() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000); + request.MarkPaymentSent(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new CancelPaymentRequestCommandHandler( + dbContext, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CancelPaymentRequestCommand(request.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.RequestNotCancellable, result.Error); + } + + [Fact] + public async Task Handle_WhenOwnedByAnotherUser_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1000); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new CancelPaymentRequestCommandHandler( + dbContext, + FakeCurrentUser.Authenticated(Guid.NewGuid()) + ); + + var result = await handler.Handle( + new CancelPaymentRequestCommand(request.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.RequestNotFound, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs new file mode 100644 index 0000000..dab5cee --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs @@ -0,0 +1,166 @@ +using NSubstitute; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Billing.CreatePaymentRequest; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Pricing; +using Xunit; + +namespace PnvPanel.Application.Tests.Billing.CreatePaymentRequest; + +public class CreatePaymentRequestCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + + private static CurrentUserProfile Profile( + Guid userId, + bool billingEnabled = true, + int maxConfigs = 5, + DateTimeOffset? paidUntil = null + ) => + new( + userId, + "alice", + Guid.NewGuid(), + "premium", + IsActivated: true, + IsBlocked: false, + MaxConfigs: maxConfigs, + MaxIpLimit: 3, + SubscriptionToken: "sub-token", + BillingEnabled: billingEnabled, + BillingPaidUntil: paidUntil, + BillingSuspended: false + ); + + [Fact] + public async Task Handle_WithConfiguredPricing_ComputesAmountAndCreatesRequest() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var pricing = PricingSettings.CreateDefault(); + pricing.Update(500, 450, 400); + dbContext.PricingSettings.Add(pricing); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, maxConfigs: 5)); + + var handler = new CreatePaymentRequestCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreatePaymentRequestCommand(PaymentPeriod.Quarter), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(500 * 5 * 3, result.Value.AmountSnapshot); + Assert.Equal(PaymentRequestStatus.AwaitingPayment, result.Value.Status); + } + + [Fact] + public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, billingEnabled: false)); + + var handler = new CreatePaymentRequestCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreatePaymentRequestCommand(PaymentPeriod.Quarter), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.NotEnabled, result.Error); + } + + [Fact] + public async Task Handle_WhenRoleHasUnlimitedConfigs_ReturnsUnlimitedRoleNotSupported() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, maxConfigs: RoleQuota.Unlimited)); + + var handler = new CreatePaymentRequestCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreatePaymentRequestCommand(PaymentPeriod.Quarter), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.UnlimitedRoleNotSupported, result.Error); + } + + [Fact] + public async Task Handle_WhenActiveRequestExists_ReturnsActiveRequestExists() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + dbContext.PaymentRequests.Add(PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000)); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId)); + + var handler = new CreatePaymentRequestCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreatePaymentRequestCommand(PaymentPeriod.Quarter), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.ActiveRequestExists, result.Error); + } + + [Fact] + public async Task Handle_WhenPricingNotConfigured_ReturnsPricingNotConfigured() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId)); + + var handler = new CreatePaymentRequestCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreatePaymentRequestCommand(PaymentPeriod.Quarter), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.PricingNotConfigured, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandlerTests.cs new file mode 100644 index 0000000..52f4e2f --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandlerTests.cs @@ -0,0 +1,85 @@ +using NSubstitute; +using PnvPanel.Application.Billing.GetMyBillingStatus; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using Xunit; + +namespace PnvPanel.Application.Tests.Billing.GetMyBillingStatus; + +public class GetMyBillingStatusQueryHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + + private static CurrentUserProfile Profile( + Guid userId, + bool billingEnabled, + DateTimeOffset? paidUntil = null, + bool suspended = false + ) => + new( + userId, + "alice", + Guid.NewGuid(), + "premium", + IsActivated: true, + IsBlocked: false, + MaxConfigs: 5, + MaxIpLimit: 3, + SubscriptionToken: "sub-token", + BillingEnabled: billingEnabled, + BillingPaidUntil: paidUntil, + BillingSuspended: suspended + ); + + [Fact] + public async Task Handle_WhenBillingNotEnabled_ReturnsDisabledStatusWithoutQueryingRequests() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, billingEnabled: false)); + + var handler = new GetMyBillingStatusQueryHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle(new GetMyBillingStatusQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.Value.BillingEnabled); + Assert.Null(result.Value.ActiveRequest); + } + + [Fact] + public async Task Handle_WhenActiveRequestExists_IncludesItInStatus() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var paidUntil = DateTimeOffset.UtcNow.AddDays(10); + var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, billingEnabled: true, paidUntil: paidUntil)); + + var handler = new GetMyBillingStatusQueryHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle(new GetMyBillingStatusQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Value.BillingEnabled); + Assert.Equal(paidUntil, result.Value.PaidUntil); + Assert.NotNull(result.Value.ActiveRequest); + Assert.Equal(request.Id, result.Value.ActiveRequest!.Id); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs new file mode 100644 index 0000000..f936059 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs @@ -0,0 +1,75 @@ +using NSubstitute; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Billing.MarkPaymentSent; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using Xunit; + +namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent; + +public class MarkPaymentSentCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + + [Fact] + public async Task Handle_WhenAwaitingPayment_MovesToAwaitingConfirmationAndNotifiesAdmins() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.Create(userId, PaymentPeriod.Year, 6000); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new MarkPaymentSentCommandHandler( + dbContext, + _identityService, + _telegramNotifier, + FakeCurrentUser.Authenticated(userId, "alice") + ); + + var result = await handler.Handle( + new MarkPaymentSentCommand(request.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status); + await _telegramNotifier + .Received(1) + .NotifyAdminsPaymentRequestedAsync( + request.Id, + Arg.Any(), + PaymentPeriod.Year, + 6000, + Arg.Any() + ); + } + + [Fact] + public async Task Handle_WhenAlreadyAwaitingConfirmation_ReturnsNotAwaitingPayment() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var request = PaymentRequest.Create(userId, PaymentPeriod.Year, 6000); + request.MarkPaymentSent(); + dbContext.PaymentRequests.Add(request); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new MarkPaymentSentCommandHandler( + dbContext, + _identityService, + _telegramNotifier, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new MarkPaymentSentCommand(request.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.RequestNotAwaitingPayment, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs b/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs index 303ecf2..d75495d 100644 --- a/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs @@ -28,7 +28,10 @@ public class RequireActivationBehaviorTests IsBlocked: false, MaxConfigs: 3, MaxIpLimit: RoleQuota.Unlimited, - SubscriptionToken: "sub-token" + SubscriptionToken: "sub-token", + BillingEnabled: false, + BillingPaidUntil: null, + BillingSuspended: false ); [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs index 53fc772..ac40875 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs @@ -47,7 +47,10 @@ public class GetMyConfigsQueryHandlerTests false, 5, RoleQuota.Unlimited, - "sub-token" + "sub-token", + false, + null, + false ); _identityService.GetProfileAsync(userId, Arg.Any()).Returns(profile); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs index 3c0d4cf..09533c7 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs @@ -27,7 +27,10 @@ public class RotateVpnConfigCommandHandlerTests false, 3, RoleQuota.Unlimited, - "sub-token" + "sub-token", + false, + null, + false ); [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs index bb8adc6..a2133b6 100644 --- a/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs @@ -25,7 +25,10 @@ public class AddTicketCommentCommandHandlerTests IsBlocked: false, MaxConfigs: 3, MaxIpLimit: RoleQuota.Unlimited, - SubscriptionToken: "token" + SubscriptionToken: "token", + BillingEnabled: false, + BillingPaidUntil: null, + BillingSuspended: false ); [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs index 0aa8ab8..3e8c977 100644 --- a/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs @@ -23,7 +23,7 @@ public class CreateRoleRequestTicketCommandHandlerTests var currentUser = FakeCurrentUser.Authenticated(userId, "alice"); _roleService .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "premium", 5, 2, false) }); + .Returns(new List { new(roleId, "premium", 5, 2, false, false) }); var handler = new CreateRoleRequestTicketCommandHandler( dbContext, @@ -61,7 +61,7 @@ public class CreateRoleRequestTicketCommandHandlerTests var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid()); _roleService .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "admin", -1, -1, true) }); + .Returns(new List { new(roleId, "admin", -1, -1, true, false) }); var handler = new CreateRoleRequestTicketCommandHandler( dbContext, diff --git a/backend/tests/PnvPanel.Application.Tests/Support/ListSelectableRoles/ListSelectableRolesQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/ListSelectableRoles/ListSelectableRolesQueryHandlerTests.cs index bc05659..9b94ee3 100644 --- a/backend/tests/PnvPanel.Application.Tests/Support/ListSelectableRoles/ListSelectableRolesQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Support/ListSelectableRoles/ListSelectableRolesQueryHandlerTests.cs @@ -21,7 +21,10 @@ public class ListSelectableRolesQueryHandlerTests IsBlocked: false, MaxConfigs: 3, MaxIpLimit: 1, - SubscriptionToken: "token" + SubscriptionToken: "token", + BillingEnabled: false, + BillingPaidUntil: null, + BillingSuspended: false ); [Fact] @@ -37,9 +40,9 @@ public class ListSelectableRolesQueryHandlerTests .Returns( new List { - new(adminRoleId, "admin", -1, -1, IsSystem: true), - new(currentRoleId, "user", 3, 1, IsSystem: true), - new(extendedRoleId, "extended", 10, 5, IsSystem: false), + new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false), + new(currentRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false), + new(extendedRoleId, "extended", 10, 5, IsSystem: false, BillingEnabled: false), } ); _identityService @@ -71,8 +74,8 @@ public class ListSelectableRolesQueryHandlerTests .Returns( new List { - new(adminRoleId, "admin", -1, -1, IsSystem: true), - new(userRoleId, "user", 3, 1, IsSystem: true), + new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false), + new(userRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false), } ); _identityService diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs index 339f9e1..81bd836 100644 --- a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs @@ -98,7 +98,10 @@ public class GetLoginRequestStatusQueryHandlerTests false, 3, RoleQuota.Unlimited, - "sub-token" + "sub-token", + false, + null, + false ); _identityService.GetProfileAsync(userId, Arg.Any()).Returns(profile); _jwtTokenService diff --git a/backend/tests/PnvPanel.Domain.Tests/Billing/PaymentRequestTests.cs b/backend/tests/PnvPanel.Domain.Tests/Billing/PaymentRequestTests.cs new file mode 100644 index 0000000..e2a8d70 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Billing/PaymentRequestTests.cs @@ -0,0 +1,101 @@ +using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Exceptions; +using Xunit; + +namespace PnvPanel.Domain.Tests.Billing; + +public class PaymentRequestTests +{ + [Fact] + public void Create_StartsInAwaitingPayment() + { + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500); + + Assert.Equal(PaymentRequestStatus.AwaitingPayment, request.Status); + Assert.Equal(1500, request.AmountSnapshot); + } + + [Fact] + public void MarkPaymentSent_FromAwaitingPayment_MovesToAwaitingConfirmation() + { + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500); + + request.MarkPaymentSent(); + + Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status); + } + + [Fact] + public void MarkPaymentSent_WhenAlreadySent_Throws() + { + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500); + request.MarkPaymentSent(); + + Assert.Throws(() => request.MarkPaymentSent()); + } + + [Fact] + public void Cancel_FromAwaitingPayment_MovesToCancelled() + { + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500); + + request.Cancel(); + + Assert.Equal(PaymentRequestStatus.Cancelled, request.Status); + } + + [Fact] + public void Cancel_AfterMarkPaymentSent_Throws() + { + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500); + request.MarkPaymentSent(); + + Assert.Throws(() => request.Cancel()); + } + + [Fact] + public void Confirm_FromAwaitingConfirmation_Succeeds() + { + var adminId = Guid.NewGuid(); + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Year, 5000); + request.MarkPaymentSent(); + + request.Confirm(adminId); + + Assert.Equal(PaymentRequestStatus.Confirmed, request.Status); + Assert.Equal(adminId, request.DecidedBy); + Assert.NotNull(request.DecidedAt); + } + + [Fact] + public void Confirm_FromAwaitingPayment_Succeeds() + { + // Админ мог увидеть оплату раньше, чем пользователь нажал "Я оплатил". + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Year, 5000); + + request.Confirm(Guid.NewGuid()); + + Assert.Equal(PaymentRequestStatus.Confirmed, request.Status); + } + + [Fact] + public void Reject_FromAwaitingConfirmation_SetsReason() + { + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.HalfYear, 3000); + request.MarkPaymentSent(); + + request.Reject(Guid.NewGuid(), "Платёж не найден"); + + Assert.Equal(PaymentRequestStatus.Rejected, request.Status); + Assert.Equal("Платёж не найден", request.RejectionReason); + } + + [Fact] + public void Confirm_WhenAlreadyDecided_Throws() + { + var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500); + request.Cancel(); + + Assert.Throws(() => request.Confirm(Guid.NewGuid())); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs b/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs index cbbb636..704703d 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Configs/VpnConfigTests.cs @@ -137,6 +137,62 @@ public class VpnConfigTests Assert.Equal(ConfigStatus.Revoked, config.Status); } + [Fact] + public void Suspend_WhenActive_SetsExpired() + { + var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null); + + config.Suspend(); + + Assert.Equal(ConfigStatus.Expired, config.Status); + } + + [Fact] + public void Suspend_WhenDisabledByAdmin_DoesNotOverrideBlock() + { + // Suspend (биллинг) не должен путать своё состояние с Disable (блокировка админом) — иначе + // Resume() ошибочно вернёт в Active конфиг, погашенный не за неуплату. + var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null); + config.Disable(); + + config.Suspend(); + + Assert.Equal(ConfigStatus.Disabled, config.Status); + } + + [Fact] + public void Resume_WhenExpired_ReturnsToActive() + { + var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null); + config.Suspend(); + + config.Resume(); + + Assert.Equal(ConfigStatus.Active, config.Status); + } + + [Fact] + public void Resume_WhenDisabledByAdmin_DoesNotResurrect() + { + var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null); + config.Disable(); + + config.Resume(); + + Assert.Equal(ConfigStatus.Disabled, config.Status); + } + + [Fact] + public void SetBillingExpiry_SetsExpiresAt() + { + var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null); + var expiresAt = DateTimeOffset.UtcNow.AddMonths(3); + + config.SetBillingExpiry(expiresAt); + + Assert.Equal(expiresAt, config.ExpiresAt); + } + [Fact] public void UpdateTraffic_SetsBytesAndLastSyncAt() { diff --git a/backend/tests/PnvPanel.IntegrationTests/Billing/BillingFlowTests.cs b/backend/tests/PnvPanel.IntegrationTests/Billing/BillingFlowTests.cs new file mode 100644 index 0000000..110bcd4 --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/Billing/BillingFlowTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Net.Http.Json; +using PnvPanel.IntegrationTests.TestSupport; +using Xunit; +using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper; + +namespace PnvPanel.IntegrationTests.Billing; + +[Collection(IntegrationTestCollection.Name)] +public class BillingFlowTests(PnvPanelWebApplicationFactory factory) +{ + private sealed record RoleResponse(Guid Id, string Name, bool BillingEnabled); + + private sealed record ActivationRequestResponse(Guid Id); + + private sealed record BillingStatusResponse( + bool BillingEnabled, + DateTimeOffset? PaidUntil, + bool Suspended, + string RequisitesText, + object? ActiveRequest + ); + + /// + /// Проверяет сквозной путь, недоступный unit-тестам (RoleService — Infrastructure/Identity): + /// назначение billing-роли автоматически выдаёт грейс-период, и он виден пользователю через + /// /api/billing/status. + /// + [Fact] + public async Task AssigningBillingRole_GrantsGracePeriod_VisibleInBillingStatus() + { + using var adminClient = factory.CreateClient(); + var adminToken = await LoginAsAdminAsync(adminClient); + adminClient.UseBearerToken(adminToken); + + var createRoleResponse = await adminClient.PostJsonAsync( + "/api/admin/roles", + new + { + name = $"billing_{Guid.NewGuid():N}"[..20], + maxConfigs = 5, + maxIpLimit = -1, + billingEnabled = true, + } + ); + Assert.Equal(HttpStatusCode.OK, createRoleResponse.StatusCode); + var role = await createRoleResponse.ReadAsAsync(); + Assert.True(role!.BillingEnabled); + + using var userClient = factory.CreateClient(); + var userName = $"bill_{Guid.NewGuid():N}"[..20]; + var (userId, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123"); + userClient.UseBearerToken(userToken); + + var activationRequestResponse = await userClient.PostJsonAsync( + "/api/activation/request", + new { comment = (string?)null } + ); + var activationRequest = + await activationRequestResponse.ReadAsAsync(); + var approveActivationResponse = await adminClient.PostAsync( + $"/api/admin/activation-requests/{activationRequest!.Id}/approve", + content: null + ); + Assert.Equal(HttpStatusCode.NoContent, approveActivationResponse.StatusCode); + + var assignRoleResponse = await adminClient.PatchAsJsonAsync( + $"/api/admin/users/{userId}/role", + new { roleId = role.Id }, + PnvPanel.IntegrationTests.TestSupport.HttpClientJsonExtensions.JsonOptions + ); + Assert.Equal(HttpStatusCode.NoContent, assignRoleResponse.StatusCode); + + var beforeCheck = DateTimeOffset.UtcNow; + var statusResponse = await userClient.GetAsync("/api/billing/status"); + Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); + var status = await statusResponse.ReadAsAsync(); + + Assert.True(status!.BillingEnabled); + Assert.False(status.Suspended); + Assert.NotNull(status.PaidUntil); + // Дефолтный грейс — 7 дней (BillingSettings.DefaultGraceDays), пока админ не настроил своё. + Assert.True(status.PaidUntil > beforeCheck.AddDays(6)); + Assert.True(status.PaidUntil < beforeCheck.AddDays(8)); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index 624c14c..b025ce3 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -147,6 +147,19 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` | | POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` | +## Billing (пользователь) + +Группа `/api/billing`, `RequireAuthorization()` + `IRequiresActivation`. Доступна независимо от роли — +`GET /status` сам сообщает `billingEnabled=false`, если биллинг для роли пользователя не включён. + +| Метод | Путь | Тело запроса | Тело ответа | +| ----- | -------------------------------------------- | ---------------------- | ------------- | +| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }` | +| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`409 Billing.ActiveRequestExists`, если уже есть активная заявка; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) | +| POST | `/api/billing/requests/{id}/cancel` | — | `204 No Content` (только из `AwaitingPayment`) | +| POST | `/api/billing/requests/{id}/mark-paid` | — | `204 No Content` (`AwaitingPayment → AwaitingConfirmation`, уведомляет админов в Telegram) | +| POST | `/api/billing/requests/{id}/send-requisites` | — | `204 No Content` (дублирует реквизиты в свой Telegram; `409 Telegram.NotLinked`, если Telegram не привязан) | + ## Support (пользователь) Группа `/api/support`, `RequireAuthorization()` + `IRequiresActivation` (кроме `GET /attachments/{id}`, @@ -245,8 +258,8 @@ reject/approve владением тикета не ограничены. Еди | POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` | | POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` | | GET | `/api/admin/roles` | admin | — | `RoleDto[]` | -| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` | -| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` | +| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (`400`, если `billingEnabled=true` для `name="admin"`) | +| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (то же ограничение на `admin`; включение `billingEnabled` ретроактивно выдаёт грейс-период уже назначенным пользователям без `PaidUntil`) | | DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) | | PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) | | GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) | @@ -255,6 +268,19 @@ reject/approve владением тикета не ограничены. Еди Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через approve/reject над `ActivationRequest`. +## Admin — Billing + +| Метод | Путь | Роль | Тело запроса | Тело ответа | +| ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- | +| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays }` | +| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays }` | `BillingSettingsDto` | +| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList` (включает `userName`) | +| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (продлевает `BillingPaidUntil`, возвращает приостановленные конфиги в `Active`) | +| POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` | + +То же подтверждение/отклонение доступно **из Telegram, не заходя на сайт** — инлайн-кнопки на +уведомлении о заявке (`pay:approve:{id}`/`pay:reject:{id}`, см. [telegram-bot.md](telegram-bot.md)). + ## Admin — Nodes | Метод | Путь | Роль | Тело запроса | Тело ответа | @@ -341,9 +367,9 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи | --- | -------------------------------------------------------------------- | | 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) | | 401 | Нет/просрочен/невалиден access-токен | -| 403 | Нет прав по роли, либо `Auth.NotActivated` | +| 403 | Нет прав по роли, либо `Auth.NotActivated`, либо `Configs.BillingRequired` (просрочена оплата) | | 404 | Ресурс не найден | -| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed` | +| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed`, `Billing.ActiveRequestExists`, `Billing.UnlimitedRoleNotSupported`, `Telegram.NotLinked` | | 422 | Прочие управляемые ошибки, не подошедшие под коды выше | | 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) | | 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) | diff --git a/docs/architecture.md b/docs/architecture.md index b601285..3c29b15 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,8 +93,8 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C (хранение/ротация/отзыв refresh-токенов), `RoleService`, `DbInitializer` (сидинг). - **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; кэш клиентов per-node внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет). -- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService`, `TrafficRetentionService` - (`BackgroundService` + `PeriodicTimer`). +- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService`, `TrafficRetentionService`, + `BillingService` (`BackgroundService` + `PeriodicTimer`). - **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest, ASP.NET Core Data Protection, key-ring на томе `dp_keys`). - **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`. @@ -221,6 +221,10 @@ POST /api/configs - **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`, шлёт `nodeStatusChanged` группе `admins`. - **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика). +- **BillingService** — раз в час обходит пользователей с billing-ролью (`AppRole.BillingEnabled`): + гасит конфиги при просрочке оплаты (`VpnConfig.Suspend()`), шлёт предупреждение за 3 дня до + истечения. Пропускает пользователей с `PaymentRequest` в `AwaitingConfirmation` — конфиги не + гасятся, пока админ не подтвердит/отклонит заявку (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)). - Реализованы как обычные `BackgroundService` + `PeriodicTimer`, без внешнего джоб-раннера (см. [tech-stack.md](tech-stack.md)). diff --git a/docs/domain-model.md b/docs/domain-model.md index 7f2f7e1..0cf221f 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -4,11 +4,14 @@ `AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser`/ `IdentityRole`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`. -Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота: -число активных конфигов на роль (`AppRole.MaxConfigs`). Есть глобальная справочная цена за один -конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным пользователям -в заявке на роль) — это не биллинг: без статусов оплаты, дат окончания -и интеграций с платёжными системами, см. ниже. +Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов — +только через `AppRole.MaxConfigs`. Есть глобальная справочная цена за один конфиг (`PricingSettings`; +редактирует только `admin`, но справочно видна и активированным пользователям в заявке на роль) — +используется и биллингом (см. ниже) для расчёта суммы заявки на оплату. + +**Биллинг (подписка по сроку) реализован, но опционален и включается per-роль** +(`AppRole.BillingEnabled`, недоступен для `admin`) — см. [Billing](#billing--подписка-по-сроку). +Роль без флага живёт как раньше, без ограничений по сроку. ## Диаграмма связей @@ -105,7 +108,7 @@ AppUser | `Protocol` | `VpnProtocol` | Денормализовано с inbound | | `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) | | `UsedDownBytes` | `long` | Синхронизируется из 3x-ui | -| `ExpiresAt` | `DateTimeOffset?`| Зарезервировано, сейчас ничего его не выставляет — конфиг живёт бессрочно | +| `ExpiresAt` | `DateTimeOffset?`| Для billing-ролей — денормализованный `AppUser.BillingPaidUntil` (см. Billing); иначе `null`, конфиг живёт бессрочно. Уходит в `Subscription-Userinfo` для VPN-клиента | | `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`| | `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` | | `LastSyncAt` | `DateTimeOffset?`| | @@ -122,6 +125,9 @@ AppUser удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки. - `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера). +- `Suspend()`/`Resume()` → меняют статус (`Active ↔ Expired`), отдельно от `Disable()`/`Enable()` — + приостановка за неуплату (биллинг) не должна конфликтовать с блокировкой админом: разблокировка + возвращает в `Active` только то, что было погашено именно блокировкой, и наоборот (см. Billing). - `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента). - Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его @@ -138,9 +144,9 @@ AppUser - Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`). - Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли). -> Лимиты трафика и автоматическое истечение срока конфига не реализованы. `ExpiresAt` никогда не -> выставляется; `ConfigStatus.LimitReached` в значении enum есть, но код в него никогда не переводит -> конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см. [tech-stack.md](tech-stack.md)). +> Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него +> никогда не переводит конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см. +> [tech-stack.md](tech-stack.md)). Истечение срока — только для billing-ролей, см. Billing ниже. ### TrafficSample — история трафика (для графиков) Точки потребления во времени; пишутся синхронизацией. @@ -290,6 +296,7 @@ UI **настойчиво напоминает** привязать его (ед | `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) | | `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) | | `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать | +| `BillingEnabled` | `bool` | Включает биллинг для пользователей с этой ролью; нельзя включить для `admin` (см. Billing) | Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`, по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2). @@ -348,6 +355,87 @@ PricePerConfigPerQuarter × 3`). admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше). +### Billing — подписка по сроку + +Опциональная подсистема: включается per-роль (`AppRole.BillingEnabled`), недоступна для `admin`. +Роль без флага не затрагивается — конфиги живут бессрочно, как без биллинга вообще. + +**`AppUser` (доп. поля, только для billing-ролей):** + +| Поле | Тип | Заметки | +| --------------------------------- | ------------------ | ------------------------------------------------------------ | +| `BillingPaidUntil` | `DateTimeOffset?` | Оплачено до этой даты; `null` — оплата ещё ни разу не выставлялась | +| `BillingSuspended` | `bool` | Конфиги приостановлены за неуплату (см. `BillingService`) | +| `BillingLastWarnedForPaidUntil` | `DateTimeOffset?` | Для какого `PaidUntil` уже отправлено предупреждение «истекает через N дней» — не даёт слать повторно на каждый тик джобы | + +**Грейс-период**: когда пользователю впервые назначается billing-роль (или роли, где он уже состоит, +включают `BillingEnabled`) и `BillingPaidUntil == null` — `RoleService` выставляет +`BillingPaidUntil = now + BillingSettings.GraceDays` автоматически (`ChangeUserRoleAsync`/ +`UpdateRoleAsync`). Без этого пользователь был бы «просрочен» с первой секунды. + +### BillingSettings — глобальные настройки биллинга +Singleton (как `PricingSettings`) — реквизиты для оплаты и длина грейс-периода, редактирует `admin`. + +| Поле | Тип | Заметки | +| ----------------- | ---------------- | ----------------------------------------------------------- | +| `Id` | `Guid` | PK | +| `RequisitesText` | `string` | Произвольный текст реквизитов (карта/крипто-адрес/СБП и т.д.), показывается пользователю с заявкой | +| `GraceDays` | `int` | По умолчанию 7 (`BillingSettings.DefaultGraceDays`) | +| `UpdatedAt` | `DateTimeOffset` | | + +### PaymentRequest — заявка на оплату +Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более +одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки на пользователя — инвариант +проверяется в `CreatePaymentRequestCommandHandler`. + +| Поле | Тип | Заметки | +| ------------------ | ----------------------- | ------------------------------------------------------------ | +| `Id` | `Guid` | PK | +| `UserId` | `Guid` | FK → AppUser (заявитель) | +| `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) | +| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`. Последующее изменение прайса админом не меняет уже созданные заявки | +| `Status` | `PaymentRequestStatus` | `AwaitingPayment` → `AwaitingConfirmation` → `Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` | +| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) | +| `CreatedAt` | `DateTimeOffset` | | + +Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле — +`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`. + +**Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`): +- `Create(userId, period, amount)` → `AwaitingPayment`, показываются реквизиты `BillingSettings`. + Пользователь может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки. +- `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`, + админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}`. +- `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и + `AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку). + `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) + период` (не теряет уже + оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги, приостановленные за + неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет `ExpiresAt` на всех + конфигах пользователя. + +### BillingService — приостановка за неуплату (фоновая джоба) +`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для +каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`): +- есть `PaymentRequest` в статусе `AwaitingConfirmation` → **пропустить** — это и есть защита «заявка + висит на подтверждении админом, а срок истёк» из требований: конфиги не гасятся, пока админ не + решит (не по вине пользователя, что админ не успел проверить оплату); +- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить все `Active` + конфиги (`Suspend()` → `Expired`, гейтвей `UpdateClientAsync(enable:false)`, идемпотентно как в + `BlockUserCommandHandler`), `AppUser.BillingSuspended = true`, Telegram-уведомление пользователю, + `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже suspended) — только + идемпотентная досуспензия «зависших» `Active`-конфигов (самовосстановление после недоступности + ноды), без повторных уведомлений; +- до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено + (`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки. + +`CreateVpnConfigCommandHandler` дополнительно не даёт создать **новый** конфиг, если роль billing +и оплата просрочена (`ConfigErrors.BillingRequired`) — иначе приостановку можно было бы обойти +созданием свежего конфига. + +`GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка +реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок, +подтверждение/отклонение), только `admin`. + ### ActivationRequest — запрос активации Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram. diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 1a08dd5..573c59d 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -75,7 +75,8 @@ | -------------------------- | --------------------------------------------------------------------------------- | | Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) | | Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) | -| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. Есть глобальная справочная цена за конфиг (`PricingSettings`, редактирует только `admin`, справочно видна и в заявке на роль) — без биллинг-логики | +| Лимиты трафика | Не реализованы — конфиги без лимитов трафика. Есть глобальная справочная цена за конфиг (`PricingSettings`, редактирует только `admin`) — ставка ₽/конфиг/месяц по периодам 3/6/12 мес | +| Биллинг (подписка по сроку) | Реализован, но **включается per-роль** (`AppRole.BillingEnabled`, недоступен для `admin`) — см. [domain-model.md](domain-model.md#billing--подписка-по-сроку). Не биллинг-система по умолчанию: роль без флага живёт без ограничений по сроку, как раньше | | i18n | RU + EN (react-i18next) | | Telegram-транспорт | Long polling | | Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) | diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 728425e..628da88 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -197,6 +197,30 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl 4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на роль отклонена.». +## Флоу 7 — Оплата подписки (биллинг) + +Только для ролей с `AppRole.BillingEnabled` (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)). +Заявка (`PaymentRequest`) заводится и решается частично на сайте, частично в Telegram — симметрично +заявке на роль: + +1. Пользователь создаёт заявку и жмёт «Я оплатил» на сайте (`/billing`) — бот в это не вовлечён, + кроме опциональной кнопки «Отправить реквизиты в Telegram» (DM самому себе для удобства, статус + заявки не меняет). +2. `MarkPaymentSentCommandHandler` вызывает + `ITelegramNotifier.NotifyAdminsPaymentRequestedAsync(requestId, userName, period, amount, ct)`. +3. Сообщение с кнопками **«✅ Подтвердить» / «❌ Отклонить»** (callback `pay:approve:{id}`/`pay:reject:{id}` + — тот же 3-частный формат, что и `rrq:*`/`act:*`). +4. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`) → `ConfirmPaymentRequestCommand`/ + `RejectPaymentRequestCommand` (те же команды, что дёргает `POST /api/admin/billing/requests/{id}/confirm|reject` + на сайте). Подтверждение продлевает `AppUser.BillingPaidUntil` и возвращает приостановленные + конфиги в `Active`. Исходное сообщение редактируется (дописывается статус), как у `rrq:*`/`act:*`. +5. Пользователю (если Telegram привязан) — DM «✅ Оплата подтверждена. Доступ продлён до {дата}.» / + «❌ Заявка на оплату отклонена.». + +Отдельно, фоновая `BillingService` (не через бота) шлёт DM-предупреждение за 3 дня до истечения +оплаты и уведомление о приостановке конфигов при просрочке — оба через `NotifyUserAsync`, без +инлайн-кнопок. + ## Команды и клавиатуры | Команда / кнопка | Действие | Требует привязки | @@ -213,6 +237,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl | «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env | | `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env | | «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env | +| «✅ Подтвердить»/«❌ Отклонить» (`pay:*`) | (admin) решение по заявке на оплату — продлевает `BillingPaidUntil` | админ по env | | «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env | Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше. diff --git a/docs/vision.md b/docs/vision.md index 7b5bd6f..e6dbfcb 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -75,8 +75,9 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует ### U2. Пользователь следит за трафиком - Фоновая синхронизация тянет трафик из 3x-ui; изменения приходят в UI через SignalR (без перезагрузки). -- Это только отображение: лимиты по трафику/сроку конфига не реализованы — единственная квота — - число активных конфигов на роль. Конфиг живёт, пока его явно не отзовут. +- Это только отображение: лимиты по трафику не реализованы — единственная квота — число активных + конфигов на роль. Конфиг живёт, пока его явно не отзовут — если только его роль не подписана на + биллинг (см. domain-model.md), тогда неоплаченный конфиг может быть временно приостановлен. ### A1. Админ подключает ноду и публикует инбаунды 1. Вводит адрес панели 3x-ui, логин/пароль (шифруются при хранении). diff --git a/frontend/src/features/admin/billing/BillingSettingsEditor.tsx b/frontend/src/features/admin/billing/BillingSettingsEditor.tsx new file mode 100644 index 0000000..2a2f284 --- /dev/null +++ b/frontend/src/features/admin/billing/BillingSettingsEditor.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from '@/shared/ui/toast-store' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { HttpError } from '@/shared/api/client' +import type { BillingSettingsDto } from '@/shared/api/types' +import { updateBillingSettings } from './api' + +export function BillingSettingsEditor({ settings }: { settings: BillingSettingsDto }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [requisitesText, setRequisitesText] = useState(settings.requisitesText) + const [graceDays, setGraceDays] = useState(String(settings.graceDays)) + const [defaultBillingEnabledForNewRoles, setDefaultBillingEnabledForNewRoles] = useState( + settings.defaultBillingEnabledForNewRoles, + ) + + const mutation = useMutation({ + mutationFn: () => + updateBillingSettings(requisitesText.trim(), Number(graceDays), defaultBillingEnabledForNewRoles), + onSuccess: async () => { + toast.success(t('admin.billing.settingsUpdated')) + await queryClient.invalidateQueries({ queryKey: ['admin-billing-settings'] }) + }, + onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), + }) + + return ( +
{ + e.preventDefault() + mutation.mutate() + }} + > +
+ +