From fad03c2834ba37e1fcf2863262c42289342f5c4e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 23 Jul 2026 22:52:20 +0300 Subject: [PATCH] Enhance user plan management and update related endpoints - Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`. - Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes. - Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users. - Removed deprecated role request approval endpoints from `AdminSupportEndpoints`. - Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications. - Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings. - Enhanced billing request handling to accommodate plan changes instead of role changes. - Updated various interfaces and command handlers to support new plan management features. --- .env.example | 9 +- .../Endpoints/AdminPlanEndpoints.cs | 69 ++ .../Endpoints/AdminSupportEndpoints.cs | 32 - .../PnvPanel.Api/Endpoints/PlanEndpoints.cs | 55 + .../PnvPanel.Api/Endpoints/RoleEndpoints.cs | 23 +- .../Endpoints/SupportEndpoints.cs | 38 - backend/src/PnvPanel.Api/Program.cs | 2 + .../Telegram/PnvBotUpdateHandler.cs | 49 - .../PnvPanel.Api/Telegram/TelegramNotifier.cs | 46 +- .../ConfirmPaymentRequestCommandHandler.cs | 8 +- .../Admin/Plans/AdminPlanDto.cs | 9 + .../Admin/Plans/CreatePlanCommand.cs | 7 + .../Admin/Plans/CreatePlanCommandHandler.cs | 21 + .../Admin/Plans/CreatePlanCommandValidator.cs | 13 + .../Admin/Plans/DeletePlanCommand.cs | 6 + .../Admin/Plans/DeletePlanCommandHandler.cs | 28 + .../Admin/Plans/ListAdminPlansQuery.cs | 6 + .../Admin/Plans/ListAdminPlansQueryHandler.cs | 25 + .../Admin/Plans/UpdatePlanCommand.cs | 12 + .../Admin/Plans/UpdatePlanCommandHandler.cs | 28 + .../Admin/Plans/UpdatePlanCommandValidator.cs | 13 + .../Admin/Roles/CreateRoleCommand.cs | 8 +- .../Admin/Roles/CreateRoleCommandHandler.cs | 1 - .../Admin/Roles/CreateRoleCommandValidator.cs | 1 - .../Admin/Roles/UpdateRoleCommand.cs | 8 +- .../Admin/Roles/UpdateRoleCommandHandler.cs | 1 - .../Admin/Roles/UpdateRoleCommandValidator.cs | 1 - .../Support/ApproveRoleRequestCommand.cs | 8 - .../ApproveRoleRequestCommandHandler.cs | 188 --- .../Support/GetTicketAdminQueryHandler.cs | 8 +- .../RejectExtensionRequestCommandHandler.cs | 5 +- .../Admin/Support/RejectRoleRequestCommand.cs | 6 - .../RejectRoleRequestCommandHandler.cs | 82 -- .../Admin/Users/AdminSetUserPlanCommand.cs | 13 + .../Users/AdminSetUserPlanCommandHandler.cs | 78 ++ .../Users/AdminSetUserPlanCommandValidator.cs | 18 + .../CreatePaymentRequestCommandHandler.cs | 8 +- .../MarkPaymentSentCommandHandler.cs | 2 +- .../SendRequisitesToTelegramCommandHandler.cs | 2 +- .../Common/Interfaces/IAppDbContext.cs | 3 + .../Common/Interfaces/IIdentityService.cs | 13 +- .../Common/Interfaces/IPlanSeeder.cs | 10 + .../Common/Interfaces/IRoleService.cs | 11 +- .../Common/Interfaces/ITelegramNotifier.cs | 15 +- .../Create/CreateVpnConfigCommandHandler.cs | 6 +- .../Configs/GetMyConfigs/GetMyConfigsQuery.cs | 6 +- .../GetMyConfigs/GetMyConfigsQueryHandler.cs | 2 +- .../Revoke/RevokeVpnConfigCommandHandler.cs | 44 +- .../Configs/VpnConfigRevocation.cs | 66 + .../Plans/ChangePlanCommand.cs | 16 + .../Plans/ChangePlanCommandHandler.cs | 229 ++++ .../Plans/ChangePlanCommandValidator.cs | 23 + .../Plans/GetMyPlanStatusQuery.cs | 14 + .../Plans/GetMyPlanStatusQueryHandler.cs | 43 + .../Plans/ListPlansQuery.cs | 6 + .../Plans/ListPlansQueryHandler.cs | 24 + .../src/PnvPanel.Application/Plans/PlanDto.cs | 8 + .../PnvPanel.Application/Plans/PlanErrors.cs | 26 + .../Plans/PlansOptions.cs | 14 + .../CreateBugReportTicketCommandHandler.cs | 5 - ...ateExtensionRequestTicketCommandHandler.cs | 5 - .../CreateRoleRequestTicketCommand.cs | 14 - .../CreateRoleRequestTicketCommandHandler.cs | 131 -- ...CreateRoleRequestTicketCommandValidator.cs | 39 - .../GetTicket/GetTicketQueryHandler.cs | 2 - .../ListSelectableRolesQuery.cs | 11 - .../ListSelectableRolesQueryHandler.cs | 37 - .../Support/SupportErrors.cs | 35 +- .../Support/TicketAuthorization.cs | 4 +- .../Support/TicketDetailDto.cs | 7 - .../Support/TicketMapping.cs | 13 - .../PnvPanel.Domain/Billing/PaymentRequest.cs | 14 +- .../Billing/PaymentRequestKind.cs | 8 +- .../Billing/PlanChangeTopUp.cs | 54 + .../Billing/RoleChangeTopUp.cs | 53 - backend/src/PnvPanel.Domain/Plans/Plan.cs | 36 + .../PnvPanel.Domain/Support/SupportTicket.cs | 52 +- .../src/PnvPanel.Domain/Support/TicketType.cs | 1 - .../BackgroundJobs/BillingService.cs | 4 +- .../DependencyInjection.cs | 4 + .../Identity/AppRole.cs | 8 +- .../Identity/AppUser.cs | 9 + .../Identity/DbInitializer.cs | 21 +- .../Identity/IdentityService.cs | 25 +- .../Identity/RoleService.cs | 35 +- .../Persistence/AppDbContext.cs | 3 + .../Configurations/PlanConfiguration.cs | 16 + .../SupportTicketConfiguration.cs | 1 - ...020_AddPlansAndUserConfigQuota.Designer.cs | 1091 +++++++++++++++++ ...260723184020_AddPlansAndUserConfigQuota.cs | 128 ++ .../Migrations/AppDbContextModelSnapshot.cs | 47 +- .../Plans/PlanSeeder.cs | 24 + ...onfirmPaymentRequestCommandHandlerTests.cs | 7 +- .../GrantBillingGiftCommandHandlerTests.cs | 3 +- .../ListPaymentRequestsQueryHandlerTests.cs | 4 +- ...RejectPaymentRequestCommandHandlerTests.cs | 3 +- .../FactoryResetCommandHandlerTests.cs | 4 +- .../Admin/Plans/PlanCommandHandlerTests.cs | 129 ++ ...roveExtensionRequestCommandHandlerTests.cs | 3 +- .../ApproveRoleRequestCommandHandlerTests.cs | 265 ---- .../RejectRoleRequestCommandHandlerTests.cs | 67 - .../ResolveTicketCommandHandlerTests.cs | 9 +- .../AdminSetUserPlanCommandHandlerTests.cs | 139 +++ .../Admin/Users/ListUsersQueryHandlerTests.cs | 6 +- .../Auth/GetCurrentUserQueryHandlerTests.cs | 1 + .../Auth/LoginCommandHandlerTests.cs | 3 +- .../Auth/RefreshCommandHandlerTests.cs | 6 +- ...CreatePaymentRequestCommandHandlerTests.cs | 3 +- .../GetMyBillingStatusQueryHandlerTests.cs | 3 +- .../MarkPaymentSentCommandHandlerTests.cs | 3 +- .../RequireActivationBehaviorTests.cs | 3 +- .../CreateVpnConfigCommandHandlerTests.cs | 3 +- .../GetMyConfigsQueryHandlerTests.cs | 3 +- .../RotateVpnConfigCommandHandlerTests.cs | 3 + .../Plans/ChangePlanCommandHandlerTests.cs | 298 +++++ .../AddTicketCommentCommandHandlerTests.cs | 3 +- ...tensionRequestTicketCommandHandlerTests.cs | 3 +- ...ateRoleRequestTicketCommandHandlerTests.cs | 133 -- .../ListSelectableRolesQueryHandlerTests.cs | 97 -- .../ReopenTicketCommandHandlerTests.cs | 8 +- .../GetLoginRequestStatusQueryHandlerTests.cs | 2 + ...eTopUpTests.cs => PlanChangeTopUpTests.cs} | 48 +- .../PnvPanel.Domain.Tests/Plans/PlanTests.cs | 31 + .../Support/SupportTicketTests.cs | 30 +- .../Configs/ConfigQuotaTests.cs | 13 +- docs/api-design.md | 105 +- docs/architecture.md | 2 +- docs/domain-model.md | 347 +++--- docs/tech-stack.md | 6 +- docs/telegram-bot.md | 39 +- .../features/admin/plans/PlanFormDialog.tsx | 98 ++ frontend/src/features/admin/plans/api.ts | 24 + .../features/admin/roles/RoleFormDialog.tsx | 11 +- frontend/src/features/admin/roles/api.ts | 8 +- .../admin/support/AdminTicketDetailDialog.tsx | 22 +- frontend/src/features/admin/support/api.ts | 8 - .../features/billing/PaymentRequestPanel.tsx | 6 +- frontend/src/features/plans/api.ts | 21 + .../support/CreateRoleRequestDialog.tsx | 198 --- .../features/support/SupportTicketList.tsx | 2 - .../features/support/TicketDetailDialog.tsx | 12 - frontend/src/features/support/api.ts | 15 - frontend/src/routeTree.gen.ts | 42 + frontend/src/routes/admin.tsx | 1 + frontend/src/routes/admin/billing.tsx | 6 +- frontend/src/routes/admin/plans.tsx | 122 ++ frontend/src/routes/admin/roles.tsx | 32 - frontend/src/routes/admin/support.tsx | 2 +- frontend/src/routes/dashboard.tsx | 8 +- frontend/src/routes/plan.tsx | 181 +++ frontend/src/shared/api/types.ts | 47 +- frontend/src/shared/lib/i18n.ts | 146 ++- 152 files changed, 4060 insertions(+), 2240 deletions(-) create mode 100644 backend/src/PnvPanel.Api/Endpoints/AdminPlanEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/PlanEndpoints.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/AdminPlanDto.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/DeletePlanCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/DeletePlanCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/ListAdminPlansQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/ListAdminPlansQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommandValidator.cs delete mode 100644 backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommand.cs delete mode 100644 backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs delete mode 100644 backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommand.cs delete mode 100644 backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IPlanSeeder.cs create mode 100644 backend/src/PnvPanel.Application/Configs/VpnConfigRevocation.cs create mode 100644 backend/src/PnvPanel.Application/Plans/ChangePlanCommand.cs create mode 100644 backend/src/PnvPanel.Application/Plans/ChangePlanCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Plans/ChangePlanCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Plans/GetMyPlanStatusQuery.cs create mode 100644 backend/src/PnvPanel.Application/Plans/GetMyPlanStatusQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Plans/ListPlansQuery.cs create mode 100644 backend/src/PnvPanel.Application/Plans/ListPlansQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Plans/PlanDto.cs create mode 100644 backend/src/PnvPanel.Application/Plans/PlanErrors.cs create mode 100644 backend/src/PnvPanel.Application/Plans/PlansOptions.cs delete mode 100644 backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommand.cs delete mode 100644 backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs delete mode 100644 backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandValidator.cs delete mode 100644 backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs delete mode 100644 backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs create mode 100644 backend/src/PnvPanel.Domain/Billing/PlanChangeTopUp.cs delete mode 100644 backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs create mode 100644 backend/src/PnvPanel.Domain/Plans/Plan.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PlanConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723184020_AddPlansAndUserConfigQuota.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723184020_AddPlansAndUserConfigQuota.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Plans/PlanSeeder.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Plans/PlanCommandHandlerTests.cs delete mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs delete mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Support/RejectRoleRequestCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Users/AdminSetUserPlanCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Plans/ChangePlanCommandHandlerTests.cs delete mode 100644 backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs delete mode 100644 backend/tests/PnvPanel.Application.Tests/Support/ListSelectableRoles/ListSelectableRolesQueryHandlerTests.cs rename backend/tests/PnvPanel.Domain.Tests/Billing/{RoleChangeTopUpTests.cs => PlanChangeTopUpTests.cs} (63%) create mode 100644 backend/tests/PnvPanel.Domain.Tests/Plans/PlanTests.cs create mode 100644 frontend/src/features/admin/plans/PlanFormDialog.tsx create mode 100644 frontend/src/features/admin/plans/api.ts create mode 100644 frontend/src/features/plans/api.ts delete mode 100644 frontend/src/features/support/CreateRoleRequestDialog.tsx create mode 100644 frontend/src/routes/admin/plans.tsx create mode 100644 frontend/src/routes/plan.tsx diff --git a/.env.example b/.env.example index 1bb1d7c..a17f4d7 100644 --- a/.env.example +++ b/.env.example @@ -32,11 +32,18 @@ AdminSeed__Username=admin AdminSeed__Password=change-me-strong-admin-password # ── Роли по умолчанию ───────────────────────────────────────────────────── -# Квота конфигов для системной роли "user" (выдаётся при регистрации). +# Стартовая квота конфигов нового пользователя (AppUser.ConfigQuota при регистрации; далее меняется +# самостоятельно через страницу тарифа /plan, не привязана к роли). Roles__DefaultUserMaxConfigs=3 # Лимит одновременных IP на клиента (limitIp в 3x-ui) для системной роли "user"; -1 = без лимита. Roles__DefaultUserMaxIpLimit=2 +# ── Тарифы (самообслуживание) ──────────────────────────────────────────── +# Верхняя граница ручного ввода количества конфигов при смене тарифа — защита от абьюза. +Plans__MaxCustomConfigCount=50 +# Нижняя граница ручного ввода — не даёт занизить квоту ниже разумного минимума. +Plans__MinCustomConfigCount=3 + # ── Rate limiting ──────────────────────────────────────────────────────── # Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20. # RateLimiting__AuthPermitLimit=20 diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminPlanEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminPlanEndpoints.cs new file mode 100644 index 0000000..36346e5 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminPlanEndpoints.cs @@ -0,0 +1,69 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Plans; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class AdminPlanEndpoints +{ + public static IEndpointRouteBuilder MapAdminPlanEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/plans") + .WithTags("Admin.Plans") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListPlans).Produces>(); + admin.MapPost("", CreatePlan).Produces(); + admin.MapPut("/{id:guid}", UpdatePlan).Produces(); + admin.MapDelete("/{id:guid}", DeletePlan).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task ListPlans(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListAdminPlansQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreatePlan( + CreatePlanCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdatePlan( + Guid id, + UpdatePlanBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var command = new UpdatePlanCommand( + id, + body.Name, + body.ConfigCount, + body.SortOrder, + body.IsEnabled + ); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeletePlan( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeletePlanCommand(id), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdatePlanBody(string Name, int ConfigCount, int SortOrder, bool IsEnabled); diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs index 8aa6506..18d3588 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs @@ -27,12 +27,6 @@ public static class AdminSupportEndpoints .MapPost("/tickets/{id:guid}/resolve", Resolve) .Produces(StatusCodes.Status204NoContent); admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent); - admin - .MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPost("/tickets/{id:guid}/reject", RejectRoleRequest) - .Produces(StatusCodes.Status204NoContent); admin .MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest) .Produces(StatusCodes.Status204NoContent); @@ -102,30 +96,6 @@ public static class AdminSupportEndpoints return result.ToHttpResult(); } - private static async Task ApproveRoleRequest( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task RejectRoleRequest( - Guid id, - RejectRoleRequestBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new RejectRoleRequestCommand(id, body.Reason), - cancellationToken - ); - return result.ToHttpResult(); - } - private static async Task ApproveExtensionRequest( Guid id, ISender sender, @@ -151,6 +121,4 @@ public static class AdminSupportEndpoints } } -public sealed record RejectRoleRequestBody(string? Reason); - public sealed record RejectExtensionRequestBody(string? Reason); diff --git a/backend/src/PnvPanel.Api/Endpoints/PlanEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/PlanEndpoints.cs new file mode 100644 index 0000000..d2e5777 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/PlanEndpoints.cs @@ -0,0 +1,55 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Plans; + +namespace PnvPanel.Api.Endpoints; + +public static class PlanEndpoints +{ + public static IEndpointRouteBuilder MapPlanEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/plans").WithTags("Plans").RequireAuthorization(); + + group.MapGet("", ListPlans).Produces>(); + group.MapGet("/status", GetMyPlanStatus).Produces(); + group.MapPost("/change", ChangePlan).Produces(); + + return app; + } + + private static async Task ListPlans(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListPlansQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetMyPlanStatus( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetMyPlanStatusQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ChangePlan( + ChangePlanBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var command = new ChangePlanCommand( + body.PlanId, + body.CustomConfigCount, + body.ConfigIdsToRevoke ?? [] + ); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record ChangePlanBody( + Guid? PlanId, + int? CustomConfigCount, + IReadOnlyList? ConfigIdsToRevoke +); diff --git a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs index 130a4f8..477339a 100644 --- a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs @@ -22,6 +22,9 @@ public static class RoleEndpoints admin .MapPatch("/users/{id:guid}/role", ChangeUserRole) .Produces(StatusCodes.Status204NoContent); + admin + .MapPatch("/users/{id:guid}/plan", SetUserPlan) + .Produces(StatusCodes.Status204NoContent); return app; } @@ -53,7 +56,7 @@ public static class RoleEndpoints ) { var result = await sender.Send( - new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit, body.BillingEnabled), + new UpdateRoleCommand(id, body.MaxIpLimit, body.BillingEnabled), cancellationToken ); return result.ToHttpResult(); @@ -82,8 +85,24 @@ public static class RoleEndpoints ); return result.ToHttpResult(); } + + private static async Task SetUserPlan( + Guid id, + SetUserPlanBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AdminSetUserPlanCommand(id, body.PlanId, body.CustomConfigCount), + cancellationToken + ); + return result.ToHttpResult(); + } } -public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit, bool BillingEnabled); +public sealed record UpdateRoleBody(int MaxIpLimit, bool BillingEnabled); public sealed record ChangeUserRoleBody(Guid RoleId); + +public sealed record SetUserPlanBody(Guid? PlanId, int? CustomConfigCount); diff --git a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs index 4c9015d..a1071c0 100644 --- a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs @@ -7,12 +7,10 @@ using PnvPanel.Application.Support; using PnvPanel.Application.Support.AddComment; using PnvPanel.Application.Support.CreateBugReport; using PnvPanel.Application.Support.CreateExtensionRequest; -using PnvPanel.Application.Support.CreateRoleRequest; using PnvPanel.Application.Support.GetAttachment; using PnvPanel.Application.Support.GetSupportPricing; using PnvPanel.Application.Support.GetTicket; using PnvPanel.Application.Support.ListMyTickets; -using PnvPanel.Application.Support.ListSelectableRoles; using PnvPanel.Application.Support.Reopen; using PnvPanel.Domain.Support; @@ -24,13 +22,11 @@ public static class SupportEndpoints { var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization(); - group.MapGet("/roles", ListSelectableRoles).Produces>(); group.MapGet("/pricing", GetSupportPricing).Produces(); group .MapPost("/tickets/bug-reports", CreateBugReport) .DisableAntiforgery() .Produces(); - group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces(); group .MapPost("/tickets/extension-requests", CreateExtensionRequest) .Produces(); @@ -46,15 +42,6 @@ public static class SupportEndpoints return app; } - private static async Task ListSelectableRoles( - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken); - return result.ToHttpResult(); - } - private static async Task GetSupportPricing( ISender sender, CancellationToken cancellationToken @@ -76,23 +63,6 @@ public static class SupportEndpoints return result.ToHttpResult(); } - private static async Task CreateRoleRequest( - CreateRoleRequestBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var command = new CreateRoleRequestTicketCommand( - body.ExistingRoleId, - body.NewRoleName, - body.NewRoleMaxConfigs, - body.NewRoleMaxIpLimit, - body.Justification - ); - var result = await sender.Send(command, cancellationToken); - return result.ToHttpResult(); - } - private static async Task CreateExtensionRequest( CreateExtensionRequestBody body, ISender sender, @@ -182,14 +152,6 @@ public static class SupportEndpoints } } -public sealed record CreateRoleRequestBody( - Guid? ExistingRoleId, - string? NewRoleName, - int? NewRoleMaxConfigs, - int? NewRoleMaxIpLimit, - string Justification -); - public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification); public sealed record ListTicketsRequest( diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index 983b709..cccb30b 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -184,11 +184,13 @@ app.MapInboundEndpoints(); app.MapConfigEndpoints(); app.MapSubscriptionEndpoints(); app.MapAppEndpoints(); +app.MapPlanEndpoints(); app.MapNewsEndpoints(); app.MapInstructionEndpoints(); app.MapAdminUserEndpoints(); app.MapAdminStatsEndpoints(); app.MapAdminAppEndpoints(); +app.MapAdminPlanEndpoints(); app.MapAdminNewsEndpoints(); app.MapAdminInstructionEndpoints(); app.MapAdminPricingEndpoints(); diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index 0ad2813..3348f9e 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -440,55 +440,6 @@ public sealed class PnvBotUpdateHandler( break; } - case "rrq": - { - if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) - { - await botClient.AnswerCallbackQuery( - callback.Id, - "Недостаточно прав.", - cancellationToken: cancellationToken - ); - return; - } - - var result = - parts[1] == "approve" - ? await sender.Send( - new ApproveRoleRequestCommand(requestId), - cancellationToken - ) - : await sender.Send( - new RejectRoleRequestCommand(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 "erq": { if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs index 4f4bb5a..f776ad3 100644 --- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -104,47 +104,6 @@ internal sealed class TelegramNotifier( } } - public async Task NotifyAdminsRoleRequestCreatedAsync( - Guid ticketId, - string userName, - string roleDescription, - string justification, - CancellationToken cancellationToken - ) - { - if (string.IsNullOrWhiteSpace(options.Value.BotToken)) - return; - - var text = - $"🆕 Заявка на роль от {Escape(userName)}\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}"; - - var keyboard = new InlineKeyboardMarkup( - new[] - { - InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"), - InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"), - } - ); - - foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) - { - try - { - await botClient.SendMessage( - adminId, - text, - parseMode: ParseMode.Html, - replyMarkup: keyboard, - cancellationToken: cancellationToken - ); - } - catch - { - // Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду. - } - } - } - public async Task NotifyAdminsExtensionRequestCreatedAsync( Guid ticketId, string userName, @@ -196,8 +155,7 @@ internal sealed class TelegramNotifier( if (string.IsNullOrWhiteSpace(options.Value.BotToken)) return; - var typeLabel = type == TicketType.RoleRequest ? "заявка на роль" : "баг/предложение"; - var text = $"🔓 Тикет от {Escape(userName)} переоткрыт ({typeLabel})"; + var text = $"🔓 Тикет от {Escape(userName)} переоткрыт (баг/предложение)"; InlineKeyboardMarkup? keyboard = null; if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) @@ -280,7 +238,7 @@ internal sealed class TelegramNotifier( if (string.IsNullOrWhiteSpace(options.Value.BotToken)) return; - var reason = kind == PaymentRequestKind.RoleChangeTopUp ? "доплате за смену роли" : $"оплате за {PeriodLabel(period!.Value)}"; + var reason = kind == PaymentRequestKind.PlanChangeTopUp ? "доплате за смену тарифа" : $"оплате за {PeriodLabel(period!.Value)}"; var text = $"💰 {Escape(userName)} заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите."; var keyboard = new InlineKeyboardMarkup( diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs index 6f54f0b..91995ac 100644 --- a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs @@ -48,7 +48,7 @@ public sealed class ConfirmPaymentRequestCommandHandler( var (request, newPaidUntil) = claimed.Value; - // RoleChangeTopUp — доплата разницы в цене при апгрейде роли, не покупка времени: подтверждение + // PlanChangeTopUp — доплата разницы в цене при увеличении тарифа, не покупка времени: подтверждение // не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление). if (request.Kind == PaymentRequestKind.Subscription) { @@ -75,8 +75,8 @@ public sealed class ConfirmPaymentRequestCommandHandler( ); var message = - request.Kind == PaymentRequestKind.RoleChangeTopUp - ? "✅ Доплата за смену роли подтверждена." + request.Kind == PaymentRequestKind.PlanChangeTopUp + ? "✅ Доплата за смену тарифа подтверждена." : $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}."; await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken); @@ -105,7 +105,7 @@ public sealed class ConfirmPaymentRequestCommandHandler( ) return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotDecidable); - if (request.Kind == PaymentRequestKind.RoleChangeTopUp) + if (request.Kind == PaymentRequestKind.PlanChangeTopUp) { request.Confirm(adminId); return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, null)); diff --git a/backend/src/PnvPanel.Application/Admin/Plans/AdminPlanDto.cs b/backend/src/PnvPanel.Application/Admin/Plans/AdminPlanDto.cs new file mode 100644 index 0000000..e4dbd24 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/AdminPlanDto.cs @@ -0,0 +1,9 @@ +using PnvPanel.Domain.Plans; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed record AdminPlanDto(Guid Id, string Name, int ConfigCount, int SortOrder, bool IsEnabled) +{ + public static AdminPlanDto FromDomain(Plan plan) => + new(plan.Id, plan.Name, plan.ConfigCount, plan.SortOrder, plan.IsEnabled); +} diff --git a/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommand.cs b/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommand.cs new file mode 100644 index 0000000..80d004d --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed record CreatePlanCommand(string Name, int ConfigCount, int SortOrder) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommandHandler.cs new file mode 100644 index 0000000..aebec6c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommandHandler.cs @@ -0,0 +1,21 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Plans; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed class CreatePlanCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public Task> Handle( + CreatePlanCommand command, + CancellationToken cancellationToken + ) + { + var plan = Plan.Create(command.Name, command.ConfigCount, command.SortOrder); + dbContext.Plans.Add(plan); + + return Task.FromResult(Result.Success(AdminPlanDto.FromDomain(plan))); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommandValidator.cs new file mode 100644 index 0000000..5c08c7c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/CreatePlanCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed class CreatePlanCommandValidator : AbstractValidator +{ + public CreatePlanCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().Length(2, 64); + RuleFor(x => x.ConfigCount).GreaterThanOrEqualTo(1); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Plans/DeletePlanCommand.cs b/backend/src/PnvPanel.Application/Admin/Plans/DeletePlanCommand.cs new file mode 100644 index 0000000..88d4a0f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/DeletePlanCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed record DeletePlanCommand(Guid PlanId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Plans/DeletePlanCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Plans/DeletePlanCommandHandler.cs new file mode 100644 index 0000000..92c4eae --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/DeletePlanCommandHandler.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.Application.Plans; + +namespace PnvPanel.Application.Admin.Plans; + +/// Удаление тарифа не трогает пользователей, у которых он уже выбран — ConfigQuota +/// снапшотится на AppUser в момент выбора (см. ChangePlanCommandHandler), PlanId лишь "последний +/// выбранный каталожный тариф" и может молча указывать на удалённую запись (как AllowedRoleIds +/// у Inbound — обычная колонка без FK, по конвенции проекта). +public sealed class DeletePlanCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle(DeletePlanCommand command, CancellationToken cancellationToken) + { + var plan = await dbContext.Plans.FirstOrDefaultAsync( + p => p.Id == command.PlanId, + cancellationToken + ); + if (plan is null) + return Result.Failure(PlanErrors.NotFound); + + dbContext.Plans.Remove(plan); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Plans/ListAdminPlansQuery.cs b/backend/src/PnvPanel.Application/Admin/Plans/ListAdminPlansQuery.cs new file mode 100644 index 0000000..7bf5a43 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/ListAdminPlansQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed record ListAdminPlansQuery : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Plans/ListAdminPlansQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Plans/ListAdminPlansQueryHandler.cs new file mode 100644 index 0000000..d6dbae6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/ListAdminPlansQueryHandler.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed class ListAdminPlansQueryHandler(IAppDbContext dbContext) + : IQueryHandler>> +{ + public async Task>> Handle( + ListAdminPlansQuery query, + CancellationToken cancellationToken + ) + { + var plans = await dbContext + .Plans.AsNoTracking() + .OrderBy(p => p.SortOrder) + .ToListAsync(cancellationToken); + + return Result.Success>( + plans.Select(AdminPlanDto.FromDomain).ToList() + ); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommand.cs b/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommand.cs new file mode 100644 index 0000000..168e975 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommand.cs @@ -0,0 +1,12 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed record UpdatePlanCommand( + Guid PlanId, + string Name, + int ConfigCount, + int SortOrder, + bool IsEnabled +) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommandHandler.cs new file mode 100644 index 0000000..c0fba26 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommandHandler.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.Application.Plans; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed class UpdatePlanCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + UpdatePlanCommand command, + CancellationToken cancellationToken + ) + { + var plan = await dbContext.Plans.FirstOrDefaultAsync( + p => p.Id == command.PlanId, + cancellationToken + ); + if (plan is null) + return Result.Failure(PlanErrors.NotFound); + + plan.Update(command.Name, command.ConfigCount, command.SortOrder, command.IsEnabled); + + return Result.Success(AdminPlanDto.FromDomain(plan)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommandValidator.cs new file mode 100644 index 0000000..6be2f43 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Plans/UpdatePlanCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Plans; + +public sealed class UpdatePlanCommandValidator : AbstractValidator +{ + public UpdatePlanCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().Length(2, 64); + RuleFor(x => x.ConfigCount).GreaterThanOrEqualTo(1); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs index e83be89..d847d8f 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs @@ -4,9 +4,5 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Admin.Roles; -public sealed record CreateRoleCommand( - string Name, - int MaxConfigs, - int MaxIpLimit, - bool BillingEnabled -) : ICommand>; +public sealed record CreateRoleCommand(string Name, 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 7aa5707..c22edf9 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs @@ -13,7 +13,6 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService) ) => roleService.CreateRoleAsync( command.Name, - command.MaxConfigs, command.MaxIpLimit, command.BillingEnabled, cancellationToken diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs index 5c20099..b993d76 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs @@ -8,7 +8,6 @@ public sealed class CreateRoleCommandValidator : AbstractValidator x.Name).NotEmpty().Length(2, 32).Matches("^[a-zA-Z0-9_-]+$"); - RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1); RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1); } } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs index 3ab13c3..9e9dc55 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs @@ -4,9 +4,5 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Admin.Roles; -public sealed record UpdateRoleCommand( - Guid RoleId, - int MaxConfigs, - int MaxIpLimit, - bool BillingEnabled -) : ICommand>; +public sealed record UpdateRoleCommand(Guid RoleId, 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 e801c51..723bd2a 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs @@ -13,7 +13,6 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService) ) => roleService.UpdateRoleAsync( command.RoleId, - command.MaxConfigs, command.MaxIpLimit, command.BillingEnabled, cancellationToken diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs index f436e57..f8ae83b 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs @@ -6,7 +6,6 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator x.MaxConfigs).GreaterThanOrEqualTo(-1); RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1); } } diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommand.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommand.cs deleted file mode 100644 index 561ec72..0000000 --- a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommand.cs +++ /dev/null @@ -1,8 +0,0 @@ -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; - -namespace PnvPanel.Application.Admin.Support; - -/// Одобрение — при новой роли сначала создаёт её (IRoleService.CreateRoleAsync), затем в -/// любом случае назначает пользователю (ChangeUserRoleAsync) и переводит тикет в Resolved. -public sealed record ApproveRoleRequestCommand(Guid TicketId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs deleted file mode 100644 index 2881377..0000000 --- a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs +++ /dev/null @@ -1,188 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using PnvPanel.Application.Auth; -using PnvPanel.Application.Common.Concurrency; -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; -using PnvPanel.Application.Support; -using PnvPanel.Domain.Audit; -using PnvPanel.Domain.Billing; -using PnvPanel.Domain.Support; - -namespace PnvPanel.Application.Admin.Support; - -/// -/// Проверка статуса + создание/назначение роли + Resolve() + доплата (если роль подорожала) — под -/// AdvisoryLock (по Id тикета): без неё одобрение с сайта, гонящееся с одобрением из Telegram по одной -/// и той же заявке, могли бы оба пройти проверку "ещё не решена" и оба создать RoleChangeTopUp — -/// двойной счёт за один апгрейд. -/// -public sealed class ApproveRoleRequestCommandHandler( - IAppDbContext dbContext, - IRoleService roleService, - IIdentityService identityService, - IRealtimeNotifier notifier, - ITelegramNotifier telegramNotifier, - ICurrentUser currentUser -) : ICommandHandler -{ - public async Task Handle( - ApproveRoleRequestCommand command, - CancellationToken cancellationToken - ) - { - if (currentUser.UserId is not { } adminId) - return Result.Failure(AuthErrors.Unauthorized); - - var claimed = await AdvisoryLock.RunAsync( - dbContext, - command.TicketId, - lockedCancellationToken => ApproveAsync(command.TicketId, adminId, lockedCancellationToken), - cancellationToken - ); - if (!claimed.IsSuccess) - return Result.Failure(claimed.Error); - - var (ticket, topUpAmount) = claimed.Value; - - await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); - await telegramNotifier.NotifyUserAsync( - ticket.UserId, - "✅ Ваша заявка на роль одобрена.", - $"/support?ticket={ticket.Id}", - cancellationToken - ); - - if (topUpAmount is { } amount) - { - await telegramNotifier.NotifyUserAsync( - ticket.UserId, - $"💳 Новая роль дороже прежней — требуется доплата {amount} ₽ за оставшуюся часть оплаченного периода.", - "/billing", - cancellationToken - ); - } - - return Result.Success(); - } - - private async Task> ApproveAsync( - Guid ticketId, - Guid adminId, - CancellationToken cancellationToken - ) - { - var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( - t => t.Id == ticketId, - cancellationToken - ); - if (ticket is null) - return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound); - - if (ticket.Type != TicketType.RoleRequest) - return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotRoleRequest); - - if (ticket.Status != TicketStatus.Open) - return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotOpen); - - // Снимаем профиль ДО смены роли — нужен старый MaxConfigs/BillingPaidUntil для проратированной - // доплаты за апгрейд (см. ниже), после ChangeUserRoleAsync эти данные уже недоступны. - var oldProfile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken); - - RoleDto newRole; - if (ticket.RequestedRoleId is { } existingRoleId) - { - var roles = await roleService.ListRolesAsync(cancellationToken); - var found = roles.FirstOrDefault(r => r.Id == existingRoleId); - if (found is null) - return Result.Failure<(SupportTicket, int?)>(SupportErrors.NotFound); - - newRole = found; - } - else - { - var createResult = await roleService.CreateRoleAsync( - ticket.ProposedRoleName!, - ticket.ProposedMaxConfigs!.Value, - ticket.ProposedMaxIpLimit!.Value, - billingEnabled: false, - cancellationToken - ); - if (!createResult.IsSuccess) - return Result.Failure<(SupportTicket, int?)>(createResult.Error); - - newRole = createResult.Value; - } - - var assignResult = await roleService.ChangeUserRoleAsync( - ticket.UserId, - newRole.Id, - cancellationToken - ); - if (!assignResult.IsSuccess) - return Result.Failure<(SupportTicket, int?)>(assignResult.Error); - - ticket.Resolve(); - - dbContext.AuditLogs.Add( - AuditLog.Create( - adminId, - "RoleRequestApproved", - "SupportTicket", - ticket.Id.ToString(), - metadata: null, - AuditSource.Web - ) - ); - - int? topUpAmount = null; - if (newRole.BillingEnabled && oldProfile?.BillingPaidUntil is { } paidUntil) - { - topUpAmount = await CreateTopUpIfNeededAsync( - ticket.UserId, - oldProfile.MaxConfigs, - newRole.MaxConfigs, - paidUntil, - cancellationToken - ); - } - - return Result.Success((ticket, topUpAmount)); - } - - /// Роль подорожала, а оплаченный период ещё активен — по-хорошему пользователь должен - /// доплатить разницу, а не доиграть апгрейд бесплатно до конца уже оплаченного срока. Роль меняется - /// сразу (см. выше); доплата решается отдельно через обычный флоу PaymentRequest — см. - /// domain-model.md#rolechangetopup. Только БД — уведомление шлёт вызывающий код после снятия лока. - private async Task CreateTopUpIfNeededAsync( - Guid userId, - int oldMaxConfigs, - int newMaxConfigs, - DateTimeOffset paidUntil, - CancellationToken cancellationToken - ) - { - var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); - if (pricing?.PricePerConfigPerQuarter is not { } rate) - return null; - - var tiers = await dbContext - .PricingDiscountTiers.AsNoTracking() - .Where(t => t.PricingSettingsId == pricing.Id) - .ToListAsync(cancellationToken); - - var amount = RoleChangeTopUp.Compute( - rate, - oldMaxConfigs, - newMaxConfigs, - tiers, - paidUntil, - DateTimeOffset.UtcNow - ); - if (amount is not { } topUpAmount) - return null; - - dbContext.PaymentRequests.Add(PaymentRequest.CreateRoleChangeTopUp(userId, topUpAmount)); - return topUpAmount; - } -} diff --git a/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQueryHandler.cs index 0340d26..91db1d5 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQueryHandler.cs @@ -6,11 +6,8 @@ using PnvPanel.Application.Support; namespace PnvPanel.Application.Admin.Support; -public sealed class GetTicketAdminQueryHandler( - IAppDbContext dbContext, - IIdentityService identityService, - IRoleService roleService -) : IQueryHandler> +public sealed class GetTicketAdminQueryHandler(IAppDbContext dbContext, IIdentityService identityService) + : IQueryHandler> { public async Task> Handle( GetTicketAdminQuery query, @@ -26,7 +23,6 @@ public sealed class GetTicketAdminQueryHandler( var dto = await TicketMapping.ToDetailDtoAsync( dbContext, identityService, - roleService, ticket, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs index 3581b6f..9e3de92 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs @@ -10,8 +10,9 @@ using PnvPanel.Domain.Support; namespace PnvPanel.Application.Admin.Support; -/// Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см. -/// ApproveRoleRequestCommandHandler для полного обоснования. +/// Проверка статуса + Close() — под AdvisoryLock (по Id тикета) — без него отклонение с +/// сайта, гонящееся с отклонением из Telegram по одной и той же заявке, могли бы оба пройти +/// проверку статуса и оба закрыть тикет. public sealed class RejectExtensionRequestCommandHandler( IAppDbContext dbContext, IRealtimeNotifier notifier, diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommand.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommand.cs deleted file mode 100644 index 64bb9fc..0000000 --- a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; - -namespace PnvPanel.Application.Admin.Support; - -public sealed record RejectRoleRequestCommand(Guid TicketId, string? Reason) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs deleted file mode 100644 index 95eab0c..0000000 --- a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using PnvPanel.Application.Auth; -using PnvPanel.Application.Common.Concurrency; -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; -using PnvPanel.Application.Support; -using PnvPanel.Domain.Audit; -using PnvPanel.Domain.Support; - -namespace PnvPanel.Application.Admin.Support; - -/// Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см. -/// ApproveRoleRequestCommandHandler для полного обоснования. -public sealed class RejectRoleRequestCommandHandler( - IAppDbContext dbContext, - IRealtimeNotifier notifier, - ITelegramNotifier telegramNotifier, - ICurrentUser currentUser -) : ICommandHandler -{ - public async Task Handle( - RejectRoleRequestCommand command, - CancellationToken cancellationToken - ) - { - if (currentUser.UserId is not { } adminId) - return Result.Failure(AuthErrors.Unauthorized); - - var claimed = await AdvisoryLock.RunAsync( - dbContext, - command.TicketId, - async lockedCancellationToken => - { - var fresh = await dbContext.SupportTickets.FirstOrDefaultAsync( - t => t.Id == command.TicketId, - lockedCancellationToken - ); - if (fresh is null) - return Result.Failure(SupportErrors.NotFound); - - if (fresh.Type != TicketType.RoleRequest) - return Result.Failure(SupportErrors.NotRoleRequest); - - if (fresh.Status == TicketStatus.Closed) - return Result.Failure(SupportErrors.AlreadyClosed); - - if (!string.IsNullOrWhiteSpace(command.Reason)) - dbContext.TicketComments.Add(TicketComment.Create(fresh.Id, adminId, command.Reason)); - - fresh.Close(); - return Result.Success(fresh); - }, - cancellationToken - ); - if (!claimed.IsSuccess) - return Result.Failure(claimed.Error); - - var ticket = claimed.Value; - - dbContext.AuditLogs.Add( - AuditLog.Create( - adminId, - "RoleRequestRejected", - "SupportTicket", - ticket.Id.ToString(), - metadata: null, - AuditSource.Web - ) - ); - - await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); - await telegramNotifier.NotifyUserAsync( - ticket.UserId, - "❌ Ваша заявка на роль отклонена.", - $"/support?ticket={ticket.Id}", - cancellationToken - ); - - return Result.Success(); - } -} diff --git a/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommand.cs new file mode 100644 index 0000000..3c5fb6d --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommand.cs @@ -0,0 +1,13 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +/// Прямой оверрайд квоты конфигов пользователя админом — ровно одно из PlanId/ +/// CustomConfigCount. В отличие от самостоятельной смены тарифа (ChangePlanCommand): не требует +/// выбора конфигов на отзыв при понижении (грандфазеринг — как при понижении роли админом сегодня: +/// лишние конфиги не трогаются, новые блокируются, пока не войдёт в квоту) и не создаёт доплату +/// (осознанный инструмент админа, может использоваться как поощрение — тот же принцип, что и у +/// прямой смены роли, см. domain-model.md). +public sealed record AdminSetUserPlanCommand(Guid UserId, Guid? PlanId, int? CustomConfigCount) + : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommandHandler.cs new file mode 100644 index 0000000..5428e52 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommandHandler.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Plans; +using PnvPanel.Domain.Audit; + +namespace PnvPanel.Application.Admin.Users; + +public sealed class AdminSetUserPlanCommandHandler( + IAppDbContext dbContext, + IIdentityService identityService, + ICurrentUser currentUser +) : ICommandHandler +{ + // Совпадает со значением Infrastructure.Identity.RoleNames.Admin (Application не может + // ссылаться на Infrastructure). + private const string AdminRoleName = "admin"; + + public async Task Handle( + AdminSetUserPlanCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(UserErrors.NotFound); + + int configQuota; + if (command.PlanId is { } planId) + { + var plan = await dbContext.Plans.AsNoTracking().FirstOrDefaultAsync( + p => p.Id == planId, + cancellationToken + ); + if (plan is null) + return Result.Failure(PlanErrors.NotFound); + + configQuota = plan.ConfigCount; + } + else + { + configQuota = command.CustomConfigCount!.Value; + } + + // Безлимитная квота — только для admin (см. domain-model.md#approle) — иначе оверрайдом + // тарифа админ мог бы выдать безлимит обычному пользователю в обход этого инварианта. + if (configQuota == RoleQuota.Unlimited) + { + var targetProfile = await identityService.GetProfileAsync(command.UserId, cancellationToken); + if (targetProfile is null) + return Result.Failure(UserErrors.NotFound); + if (!targetProfile.Role.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase)) + return Result.Failure(PlanErrors.UnlimitedOnlyForAdmin); + } + + var result = await identityService.SetConfigQuotaAsync( + command.UserId, + configQuota, + command.PlanId, + cancellationToken + ); + if (!result.IsSuccess) + return result; + + dbContext.AuditLogs.Add( + AuditLog.Create( + adminId, + "UserPlanChanged", + "User", + command.UserId.ToString(), + metadata: $"{{\"newConfigQuota\":{configQuota}}}", + AuditSource.Web + ) + ); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommandValidator.cs new file mode 100644 index 0000000..5feb098 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/AdminSetUserPlanCommandValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Users; + +public sealed class AdminSetUserPlanCommandValidator : AbstractValidator +{ + public AdminSetUserPlanCommandValidator() + { + RuleFor(x => x) + .Must(x => x.PlanId.HasValue ^ x.CustomConfigCount.HasValue) + .WithMessage("Укажите либо PlanId, либо CustomConfigCount — ровно одно из двух."); + + When( + x => x.CustomConfigCount.HasValue, + () => RuleFor(x => x.CustomConfigCount!.Value).GreaterThanOrEqualTo(-1) + ); + } +} diff --git a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs index 58ec948..692c551 100644 --- a/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandler.cs @@ -33,7 +33,7 @@ public sealed class CreatePaymentRequestCommandHandler( if (!profile.BillingEnabled) return Result.Failure(BillingErrors.NotEnabled); - if (profile.MaxConfigs == RoleQuota.Unlimited) + if (profile.ConfigQuota == RoleQuota.Unlimited) return Result.Failure(BillingErrors.UnlimitedRoleNotSupported); var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); @@ -54,16 +54,16 @@ public sealed class CreatePaymentRequestCommandHandler( .PricingDiscountTiers.AsNoTracking() .Where(t => t.PricingSettingsId == pricing.Id) .ToListAsync(cancellationToken); - var discountPercent = PricingDiscount.ResolvePercent(discountTiers, profile.MaxConfigs); + var discountPercent = PricingDiscount.ResolvePercent(discountTiers, profile.ConfigQuota); - var amount = PricingDiscount.Apply(rate * profile.MaxConfigs * command.Period.ToMonths(), discountPercent); + var amount = PricingDiscount.Apply(rate * profile.ConfigQuota * command.Period.ToMonths(), discountPercent); var claimed = await AdvisoryLock.RunAsync( dbContext, userId, async lockedCancellationToken => { - // RoleChangeTopUp не считается активной подписной заявкой — доплата за смену роли не + // PlanChangeTopUp не считается активной подписной заявкой — доплата за смену тарифа не // должна мешать пользователю продлить/оформить обычную подписку. var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync( r => diff --git a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs index 75fc6b8..d966865 100644 --- a/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Billing/MarkPaymentSent/MarkPaymentSentCommandHandler.cs @@ -40,7 +40,7 @@ public sealed class MarkPaymentSentCommandHandler( // Оплаченный период уже мог истечь, пока пользователь собирался заплатить — не ждём часовой // тик BillingService, сразу держим клиента рабочим на панели на время рассмотрения заявки // (см. BillingConfigResumer.ProtectPendingConfigsAsync). Только для Subscription — доплата за - // смену роли (RoleChangeTopUp) на приостановку не влияет. + // смену тарифа (PlanChangeTopUp) на приостановку не влияет. if ( request.Kind == PaymentRequestKind.Subscription && (profile?.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow) diff --git a/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs b/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs index fa98c3b..c44b574 100644 --- a/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Billing/SendRequisitesToTelegram/SendRequisitesToTelegramCommandHandler.cs @@ -46,7 +46,7 @@ public sealed class SendRequisitesToTelegramCommandHandler( } private static string DescribeRequest(PaymentRequest request) => - request.Kind == PaymentRequestKind.RoleChangeTopUp ? "доплата за смену роли" : PeriodLabel(request.Period!.Value); + request.Kind == PaymentRequestKind.PlanChangeTopUp ? "доплата за смену тарифа" : PeriodLabel(request.Period!.Value); private static string PeriodLabel(PaymentPeriod period) => period switch diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs index 56ba297..d5bf26c 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -9,6 +9,7 @@ using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Instructions; using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Plans; using PnvPanel.Domain.Pricing; using PnvPanel.Domain.Support; using PnvPanel.Domain.Telegram; @@ -55,6 +56,8 @@ public interface IAppDbContext DbSet PaymentRequests { get; } + DbSet Plans { 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 cc97a3d..845401d 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -11,7 +11,8 @@ public sealed record CurrentUserProfile( string Role, bool IsActivated, bool IsBlocked, - int MaxConfigs, + int ConfigQuota, + Guid? PlanId, int MaxIpLimit, string SubscriptionToken, bool BillingEnabled, @@ -188,4 +189,14 @@ public interface IIdentityService DateTimeOffset paidUntil, CancellationToken cancellationToken ); + + /// Устанавливает фактическую квоту конфигов пользователя и (опционально) какой + /// каталожный Plan выбран — вызывается из ChangePlanCommandHandler (самообслуживание) и + /// AdminSetUserPlanCommandHandler (оверрайд админом). + Task SetConfigQuotaAsync( + Guid userId, + int configQuota, + Guid? planId, + CancellationToken cancellationToken + ); } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IPlanSeeder.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IPlanSeeder.cs new file mode 100644 index 0000000..6ccdb3a --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IPlanSeeder.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// +/// Сидинг дефолтного каталога тарифов (Plan). Идемпотентно — не трогает таблицу, если в ней уже +/// есть строки (используется и при старте, и после полного сброса панели). +/// +public interface IPlanSeeder +{ + Task SeedIfEmptyAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs index 0362e05..4a709f0 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs @@ -2,20 +2,12 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Common.Interfaces; -public sealed record RoleDto( - Guid Id, - string Name, - int MaxConfigs, - int MaxIpLimit, - bool IsSystem, - bool BillingEnabled -); +public sealed record RoleDto(Guid Id, string Name, int MaxIpLimit, bool IsSystem, bool BillingEnabled); public interface IRoleService { Task> CreateRoleAsync( string name, - int maxConfigs, int maxIpLimit, bool billingEnabled, CancellationToken cancellationToken @@ -23,7 +15,6 @@ public interface IRoleService Task> UpdateRoleAsync( 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 ff722f1..9a06712 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -32,15 +32,6 @@ public interface ITelegramNotifier CancellationToken cancellationToken ); - /// Заявка на роль — инлайн-кнопки «Одобрить/Отклонить», решается полностью в Telegram. - Task NotifyAdminsRoleRequestCreatedAsync( - Guid ticketId, - string userName, - string roleDescription, - string justification, - CancellationToken cancellationToken - ); - /// Рассылка о публикации новости всем активированным пользователям с привязанным Telegram /// (кнопка-ссылка на сайт, где новость показана целиком). Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken); @@ -55,8 +46,8 @@ public interface ITelegramNotifier ); /// Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается - /// полностью в Telegram (аналогично заявке на роль). Period задан только для Kind.Subscription — - /// для Kind.RoleChangeTopUp он null (доплата не привязана к тарифному периоду). + /// полностью в Telegram. Period задан только для Kind.Subscription — для Kind.PlanChangeTopUp он + /// null (доплата не привязана к тарифному периоду). Task NotifyAdminsPaymentRequestedAsync( Guid requestId, string userName, @@ -67,7 +58,7 @@ public interface ITelegramNotifier ); /// Заявка на продление оплаченного периода — инлайн-кнопки «Одобрить/Отклонить», решается - /// полностью в Telegram (аналогично заявке на роль). + /// полностью в Telegram. Task NotifyAdminsExtensionRequestCreatedAsync( Guid ticketId, string userName, diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs index 3b32984..4617162 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -59,7 +59,7 @@ public sealed class CreateVpnConfigCommandHandler( var reserveResult = await ReserveQuotaSlotAsync( userId, - profile.MaxConfigs, + profile.ConfigQuota, config, cancellationToken ); @@ -103,7 +103,7 @@ public sealed class CreateVpnConfigCommandHandler( /// private async Task ReserveQuotaSlotAsync( Guid userId, - int maxConfigs, + int configQuota, VpnConfig config, CancellationToken cancellationToken ) @@ -122,7 +122,7 @@ public sealed class CreateVpnConfigCommandHandler( cancellationToken ); - if (maxConfigs != RoleQuota.Unlimited && activeCount >= maxConfigs) + if (configQuota != RoleQuota.Unlimited && activeCount >= configQuota) { await transaction.RollbackAsync(cancellationToken); return Result.Failure(ConfigErrors.QuotaExceeded); diff --git a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs index 55515fb..1d6a24c 100644 --- a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs +++ b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs @@ -5,4 +5,8 @@ namespace PnvPanel.Application.Configs.GetMyConfigs; public sealed record GetMyConfigsQuery : IQuery>, IRequiresActivation; -public sealed record GetMyConfigsResult(IReadOnlyList Configs, int MaxConfigs); +public sealed record GetMyConfigsResult( + IReadOnlyList Configs, + int ConfigQuota, + Guid? PlanId +); diff --git a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs index 0f2303d..a930921 100644 --- a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs @@ -39,6 +39,6 @@ public sealed class GetMyConfigsQueryHandler( var dtos = rows.Select(x => VpnConfigDto.FromDomain(x.Config, x.Inbound)).ToList(); - return Result.Success(new GetMyConfigsResult(dtos, profile.MaxConfigs)); + return Result.Success(new GetMyConfigsResult(dtos, profile.ConfigQuota, profile.PlanId)); } } diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs index 57dd507..b307584 100644 --- a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs @@ -34,42 +34,16 @@ public sealed class RevokeVpnConfigCommandHandler( if (config.Status == ConfigStatus.Revoked) return Result.Success(); - 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); + var revokeResult = await VpnConfigRevocation.RevokeAsync( + dbContext, + gateway, + config, + logger, + cancellationToken + ); + if (!revokeResult.IsSuccess) + return revokeResult; - // inbound.IsAvailable=false значит синхронизация уже подтвердила, что его нет на панели — - // звать RemoveClientAsync незачем (и оно всё равно упадёт: инбаунда для клиента не существует). - if (inbound is not null && inbound.IsAvailable && node is not null) - { - var removeResult = await gateway.RemoveClientAsync( - node, - inbound.RemoteInboundId, - config.ClientExternalId, - config.Protocol, - cancellationToken - ); - - if (!removeResult.IsSuccess) - { - // Нода недоступна/сбой панели — не помечаем конфиг Revoked локально, иначе БД - // разойдётся с реальным состоянием клиента в 3x-ui. Пользователь может повторить. - logger.LogWarning( - "Failed to remove client for config {ConfigId} on node {NodeId}: {Error}", - config.Id, - node.Id, - removeResult.Error - ); - return Result.Failure(ConfigErrors.NodeUnavailable); - } - } - - config.Revoke(); await notifier.NotifyConfigStatusChangedAsync( userId, config.Id, diff --git a/backend/src/PnvPanel.Application/Configs/VpnConfigRevocation.cs b/backend/src/PnvPanel.Application/Configs/VpnConfigRevocation.cs new file mode 100644 index 0000000..2e7945f --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/VpnConfigRevocation.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Configs; + +/// +/// Общий шаг «удалить клиента в 3x-ui (если инбаунд ещё доступен) → пометить конфиг Revoked» — +/// используется и одиночным отзывом (RevokeVpnConfigCommandHandler), и массовым отзывом при +/// самостоятельном понижении тарифа (ChangePlanCommandHandler). Не вызывает SaveChangesAsync — +/// это ответственность вызывающей стороны/AdvisoryLock. +/// +internal static class VpnConfigRevocation +{ + public static async Task RevokeAsync( + IAppDbContext dbContext, + IXuiPanelGateway gateway, + VpnConfig config, + ILogger logger, + CancellationToken cancellationToken + ) + { + if (config.Status == ConfigStatus.Revoked) + return Result.Success(); + + 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); + + // inbound.IsAvailable=false значит синхронизация уже подтвердила, что его нет на панели — + // звать RemoveClientAsync незачем (и оно всё равно упадёт: инбаунда для клиента не существует). + if (inbound is not null && inbound.IsAvailable && node is not null) + { + var removeResult = await gateway.RemoveClientAsync( + node, + inbound.RemoteInboundId, + config.ClientExternalId, + config.Protocol, + cancellationToken + ); + + if (!removeResult.IsSuccess) + { + // Нода недоступна/сбой панели — не помечаем конфиг Revoked локально, иначе БД + // разойдётся с реальным состоянием клиента в 3x-ui. Пользователь может повторить. + logger.LogWarning( + "Failed to remove client for config {ConfigId} on node {NodeId}: {Error}", + config.Id, + node.Id, + removeResult.Error + ); + return Result.Failure(ConfigErrors.NodeUnavailable); + } + } + + config.Revoke(); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Plans/ChangePlanCommand.cs b/backend/src/PnvPanel.Application/Plans/ChangePlanCommand.cs new file mode 100644 index 0000000..4f8d387 --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/ChangePlanCommand.cs @@ -0,0 +1,16 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Plans; + +/// Ровно одно из PlanId/CustomConfigCount задано. ConfigIdsToRevoke обязателен и должен +/// содержать ровно (текущее число конфигов, занимающих квоту (Active + Expired) - новая квота) id, +/// если новая квота меньше этого числа — самостоятельное понижение тарифа требует явного выбора, +/// какие конфиги отозвать, а не молчаливый грандфазеринг (см. ChangePlanCommandHandler). +public sealed record ChangePlanCommand( + Guid? PlanId, + int? CustomConfigCount, + IReadOnlyList ConfigIdsToRevoke +) : ICommand>, IRequiresActivation; + +public sealed record ChangePlanResultDto(int ConfigQuota, Guid? PlanId, int? TopUpAmount); diff --git a/backend/src/PnvPanel.Application/Plans/ChangePlanCommandHandler.cs b/backend/src/PnvPanel.Application/Plans/ChangePlanCommandHandler.cs new file mode 100644 index 0000000..7ee2182 --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/ChangePlanCommandHandler.cs @@ -0,0 +1,229 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Concurrency; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Pricing; + +namespace PnvPanel.Application.Plans; + +/// +/// Смена тарифа применяется сразу (без подтверждения админа). При увеличении квоты с активным +/// оплаченным периодом billing-роли создаётся доплата (PlanChangeTopUp), как при апгрейде роли +/// раньше. При уменьшении квоты ниже текущего числа конфигов, которые всё ещё занимают место в +/// квоте (Active + Expired — см. ниже), пользователь обязан явно выбрать, какие отозвать +/// (ConfigIdsToRevoke) — не молчаливый грандфазеринг, в отличие от понижения роли/квоты админом +/// (см. AdminSetUserPlanCommandHandler). +/// +public sealed class ChangePlanCommandHandler( + IAppDbContext dbContext, + IIdentityService identityService, + IXuiPanelGateway gateway, + IRealtimeNotifier notifier, + ITelegramNotifier telegramNotifier, + ICurrentUser currentUser, + ILogger logger +) : ICommandHandler> +{ + public async Task> Handle( + ChangePlanCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + int newConfigCount; + Guid? planId = command.PlanId; + if (command.PlanId is { } requestedPlanId) + { + var plan = await dbContext.Plans.AsNoTracking().FirstOrDefaultAsync( + p => p.Id == requestedPlanId, + cancellationToken + ); + if (plan is null) + return Result.Failure(PlanErrors.NotFound); + if (!plan.IsEnabled) + return Result.Failure(PlanErrors.Disabled); + + newConfigCount = plan.ConfigCount; + } + else + { + newConfigCount = command.CustomConfigCount!.Value; + } + + var claimed = await AdvisoryLock.RunAsync( + dbContext, + userId, + lockedCancellationToken => + ReserveAsync( + userId, + newConfigCount, + planId, + command.ConfigIdsToRevoke, + lockedCancellationToken + ), + cancellationToken + ); + if (!claimed.IsSuccess) + return Result.Failure(claimed.Error); + + var (configsToRevoke, topUpAmount) = claimed.Value; + + foreach (var config in configsToRevoke) + { + var revokeResult = await VpnConfigRevocation.RevokeAsync( + dbContext, + gateway, + config, + logger, + cancellationToken + ); + if (!revokeResult.IsSuccess) + return Result.Failure(revokeResult.Error); + } + + if (configsToRevoke.Count > 0) + await dbContext.SaveChangesAsync(cancellationToken); + + foreach (var config in configsToRevoke) + { + await notifier.NotifyConfigStatusChangedAsync( + userId, + config.Id, + config.Status, + cancellationToken + ); + } + + if (topUpAmount is { } amount) + { + await telegramNotifier.NotifyUserAsync( + userId, + $"💳 Тариф увеличен — доплата {amount} ₽ за оставшийся оплаченный период.", + "/billing", + cancellationToken + ); + } + + return Result.Success(new ChangePlanResultDto(newConfigCount, planId, topUpAmount)); + } + + private async Task ConfigsToRevoke, int? TopUpAmount)>> ReserveAsync( + Guid userId, + int newConfigCount, + Guid? planId, + IReadOnlyList configIdsToRevoke, + CancellationToken cancellationToken + ) + { + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure<(IReadOnlyList, int?)>(AuthErrors.Unauthorized); + + var oldConfigCount = profile.ConfigQuota; + + // Expired (приостановленные за неуплату) всё ещё "занимают" квоту — они возвращаются в + // Active целиком при следующей оплате (BillingConfigResumer.ResumeConfigsAsync), которая + // квоту не проверяет. Если считать здесь только Active, пользователь мог бы обойти пикер + // отзыва, понизив тариф именно в момент приостановки (все конфиги временно не Active), а + // затем оплатить — и получить обратно больше конфигов, чем позволяет новый тариф. + var liveConfigs = await dbContext + .VpnConfigs.Where(c => + c.UserId == userId + && (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired) + ) + .ToListAsync(cancellationToken); + + var excess = liveConfigs.Count - newConfigCount; + var configsToRevoke = new List(); + if (excess > 0) + { + if (configIdsToRevoke.Count != excess) + return Result.Failure<(IReadOnlyList, int?)>( + PlanErrors.MustSelectConfigsToRevoke(excess) + ); + + configsToRevoke = liveConfigs.Where(c => configIdsToRevoke.Contains(c.Id)).ToList(); + if (configsToRevoke.Count != excess) + return Result.Failure<(IReadOnlyList, int?)>(ConfigErrors.NotFound); + } + + var quotaResult = await identityService.SetConfigQuotaAsync( + userId, + newConfigCount, + planId, + cancellationToken + ); + if (!quotaResult.IsSuccess) + return Result.Failure<(IReadOnlyList, int?)>(quotaResult.Error); + + int? topUpAmount = null; + if ( + profile.BillingEnabled + && newConfigCount > oldConfigCount + && profile.BillingPaidUntil is { } paidUntil + ) + { + topUpAmount = await CreateTopUpIfNeededAsync( + userId, + oldConfigCount, + newConfigCount, + paidUntil, + cancellationToken + ); + } + + dbContext.AuditLogs.Add( + AuditLog.Create( + userId, + "PlanChanged", + "User", + userId.ToString(), + metadata: $"{{\"oldConfigQuota\":{oldConfigCount},\"newConfigQuota\":{newConfigCount}}}", + AuditSource.Web + ) + ); + + return Result.Success<(IReadOnlyList, int?)>((configsToRevoke, topUpAmount)); + } + + private async Task CreateTopUpIfNeededAsync( + Guid userId, + int oldConfigCount, + int newConfigCount, + DateTimeOffset paidUntil, + CancellationToken cancellationToken + ) + { + var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); + if (pricing?.PricePerConfigPerQuarter is not { } rate) + return null; + + var tiers = await dbContext + .PricingDiscountTiers.AsNoTracking() + .Where(t => t.PricingSettingsId == pricing.Id) + .ToListAsync(cancellationToken); + + var amount = PlanChangeTopUp.Compute( + rate, + oldConfigCount, + newConfigCount, + tiers, + paidUntil, + DateTimeOffset.UtcNow + ); + if (amount is not { } topUpAmount) + return null; + + dbContext.PaymentRequests.Add(PaymentRequest.CreatePlanChangeTopUp(userId, topUpAmount)); + return topUpAmount; + } +} diff --git a/backend/src/PnvPanel.Application/Plans/ChangePlanCommandValidator.cs b/backend/src/PnvPanel.Application/Plans/ChangePlanCommandValidator.cs new file mode 100644 index 0000000..3a1acfd --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/ChangePlanCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using Microsoft.Extensions.Options; + +namespace PnvPanel.Application.Plans; + +public sealed class ChangePlanCommandValidator : AbstractValidator +{ + public ChangePlanCommandValidator(IOptions plansOptions) + { + RuleFor(x => x) + .Must(x => x.PlanId.HasValue ^ x.CustomConfigCount.HasValue) + .WithMessage("Укажите либо PlanId, либо CustomConfigCount — ровно одно из двух."); + + When( + x => x.CustomConfigCount.HasValue, + () => + RuleFor(x => x.CustomConfigCount!.Value) + .InclusiveBetween(plansOptions.Value.MinCustomConfigCount, plansOptions.Value.MaxCustomConfigCount) + ); + + RuleFor(x => x.ConfigIdsToRevoke).Must(ids => ids.Distinct().Count() == ids.Count); + } +} diff --git a/backend/src/PnvPanel.Application/Plans/GetMyPlanStatusQuery.cs b/backend/src/PnvPanel.Application/Plans/GetMyPlanStatusQuery.cs new file mode 100644 index 0000000..77832da --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/GetMyPlanStatusQuery.cs @@ -0,0 +1,14 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Plans; + +public sealed record GetMyPlanStatusQuery : IQuery>, IRequiresActivation; + +public sealed record MyPlanStatusDto( + int ConfigQuota, + Guid? PlanId, + int ActiveConfigCount, + bool BillingEnabled, + DateTimeOffset? BillingPaidUntil +); diff --git a/backend/src/PnvPanel.Application/Plans/GetMyPlanStatusQueryHandler.cs b/backend/src/PnvPanel.Application/Plans/GetMyPlanStatusQueryHandler.cs new file mode 100644 index 0000000..1823191 --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/GetMyPlanStatusQueryHandler.cs @@ -0,0 +1,43 @@ +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.Configs; + +namespace PnvPanel.Application.Plans; + +public sealed class GetMyPlanStatusQueryHandler( + IAppDbContext dbContext, + IIdentityService identityService, + ICurrentUser currentUser +) : IQueryHandler> +{ + public async Task> Handle( + GetMyPlanStatusQuery 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); + + var activeCount = await dbContext.VpnConfigs.CountAsync( + c => c.UserId == userId && c.Status == ConfigStatus.Active, + cancellationToken + ); + + return Result.Success( + new MyPlanStatusDto( + profile.ConfigQuota, + profile.PlanId, + activeCount, + profile.BillingEnabled, + profile.BillingPaidUntil + ) + ); + } +} diff --git a/backend/src/PnvPanel.Application/Plans/ListPlansQuery.cs b/backend/src/PnvPanel.Application/Plans/ListPlansQuery.cs new file mode 100644 index 0000000..641ee3f --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/ListPlansQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Plans; + +public sealed record ListPlansQuery : IQuery>>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Plans/ListPlansQueryHandler.cs b/backend/src/PnvPanel.Application/Plans/ListPlansQueryHandler.cs new file mode 100644 index 0000000..c81107b --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/ListPlansQueryHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Plans; + +public sealed class ListPlansQueryHandler(IAppDbContext dbContext) + : IQueryHandler>> +{ + public async Task>> Handle( + ListPlansQuery query, + CancellationToken cancellationToken + ) + { + var plans = await dbContext + .Plans.AsNoTracking() + .Where(p => p.IsEnabled) + .OrderBy(p => p.SortOrder) + .ToListAsync(cancellationToken); + + return Result.Success>(plans.Select(PlanDto.FromDomain).ToList()); + } +} diff --git a/backend/src/PnvPanel.Application/Plans/PlanDto.cs b/backend/src/PnvPanel.Application/Plans/PlanDto.cs new file mode 100644 index 0000000..b9352b3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/PlanDto.cs @@ -0,0 +1,8 @@ +using PnvPanel.Domain.Plans; + +namespace PnvPanel.Application.Plans; + +public sealed record PlanDto(Guid Id, string Name, int ConfigCount) +{ + public static PlanDto FromDomain(Plan plan) => new(plan.Id, plan.Name, plan.ConfigCount); +} diff --git a/backend/src/PnvPanel.Application/Plans/PlanErrors.cs b/backend/src/PnvPanel.Application/Plans/PlanErrors.cs new file mode 100644 index 0000000..21856cc --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/PlanErrors.cs @@ -0,0 +1,26 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Plans; + +public static class PlanErrors +{ + public static readonly Error NotFound = Error.NotFound("Plans.NotFound", "Тариф не найден."); + + public static readonly Error Disabled = Error.Validation( + "Plans.Disabled", + "Тариф отключён." + ); + + public static Error MustSelectConfigsToRevoke(int requiredCount) => + Error.Validation( + "Plans.MustSelectConfigsToRevoke", + $"Новый тариф меньше текущего количества активных конфигов — выберите {requiredCount} конфиг(ов) для отзыва." + ); + + /// Безлимитная квота (-1) зарезервирована за ролью admin — обычному пользователю + /// админ не может выставить безлимит напрямую через оверрайд тарифа. + public static readonly Error UnlimitedOnlyForAdmin = Error.Validation( + "Plans.UnlimitedOnlyForAdmin", + "Безлимитная квота конфигов доступна только для роли admin." + ); +} diff --git a/backend/src/PnvPanel.Application/Plans/PlansOptions.cs b/backend/src/PnvPanel.Application/Plans/PlansOptions.cs new file mode 100644 index 0000000..cbe322d --- /dev/null +++ b/backend/src/PnvPanel.Application/Plans/PlansOptions.cs @@ -0,0 +1,14 @@ +namespace PnvPanel.Application.Plans; + +public sealed class PlansOptions +{ + public const string SectionName = "Plans"; + + /// Верхняя граница ручного ввода количества конфигов в ChangePlanCommand — защита от + /// абьюза (пользователь мог бы иначе указать сколь угодно большое число). + public int MaxCustomConfigCount { get; init; } = 50; + + /// Нижняя граница ручного ввода — не даёт самому себе занизить квоту ниже разумного + /// минимума (по умолчанию совпадает с дефолтной квотой при регистрации, Roles__DefaultUserMaxConfigs). + public int MinCustomConfigCount { get; init; } = 3; +} diff --git a/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs index a133551..1c34959 100644 --- a/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs @@ -73,11 +73,6 @@ public sealed class CreateBugReportTicketCommandHandler( ticket.Type, ticket.Status, null, - null, - null, - null, - null, - null, ticket.CreatedAt, [commentDto] ); diff --git a/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs index 39c3872..749bba3 100644 --- a/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs @@ -95,11 +95,6 @@ public sealed class CreateExtensionRequestTicketCommandHandler( userName, ticket.Type, ticket.Status, - null, - null, - null, - null, - null, ticket.RequestedDays, ticket.CreatedAt, [commentDto] diff --git a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommand.cs b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommand.cs deleted file mode 100644 index 54f01b1..0000000 --- a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; - -namespace PnvPanel.Application.Support.CreateRoleRequest; - -/// Ровно один из вариантов: ExistingRoleId, либо NewRoleName+NewRoleMaxConfigs+NewRoleMaxIpLimit -/// (проверяется валидатором). -public sealed record CreateRoleRequestTicketCommand( - Guid? ExistingRoleId, - string? NewRoleName, - int? NewRoleMaxConfigs, - int? NewRoleMaxIpLimit, - string Justification -) : ICommand>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs deleted file mode 100644 index b58aebe..0000000 --- a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs +++ /dev/null @@ -1,131 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using PnvPanel.Application.Auth; -using PnvPanel.Application.Common.Concurrency; -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; -using PnvPanel.Domain.Support; - -namespace PnvPanel.Application.Support.CreateRoleRequest; - -/// Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см. -/// CreatePaymentRequestCommandHandler для полного обоснования. -public sealed class CreateRoleRequestTicketCommandHandler( - IAppDbContext dbContext, - IRoleService roleService, - IRealtimeNotifier notifier, - ITelegramNotifier telegramNotifier, - ICurrentUser currentUser -) : ICommandHandler> -{ - // Совпадает со значением Infrastructure.Identity.RoleNames.Admin — Application не может ссылаться - // на Infrastructure (направление зависимостей), поэтому системное имя роли продублировано здесь. - private const string AdminRoleName = "admin"; - - public async Task> Handle( - CreateRoleRequestTicketCommand command, - CancellationToken cancellationToken - ) - { - if (currentUser.UserId is not { } userId) - return Result.Failure(AuthErrors.Unauthorized); - - string? requestedRoleName = null; - if (command.ExistingRoleId is { } existingRoleId) - { - var roles = await roleService.ListRolesAsync(cancellationToken); - var role = roles.FirstOrDefault(r => r.Id == existingRoleId); - if (role is null) - return Result.Failure(SupportErrors.RoleNotFound); - - if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase)) - return Result.Failure(SupportErrors.CannotRequestAdminRole); - - requestedRoleName = role.Name; - } - - var claimed = await AdvisoryLock.RunAsync( - dbContext, - userId, - async lockedCancellationToken => - { - var hasPending = await dbContext.SupportTickets.AnyAsync( - t => - t.UserId == userId - && t.Type == TicketType.RoleRequest - && t.Status == TicketStatus.Open, - lockedCancellationToken - ); - if (hasPending) - return Result.Failure<(SupportTicket, TicketComment)>( - SupportErrors.RoleRequestAlreadyPending - ); - - var newTicket = - command.ExistingRoleId is { } roleId - ? SupportTicket.CreateRoleRequestForExistingRole(userId, roleId) - : SupportTicket.CreateRoleRequestForNewRole( - userId, - command.NewRoleName!, - command.NewRoleMaxConfigs!.Value, - command.NewRoleMaxIpLimit!.Value - ); - dbContext.SupportTickets.Add(newTicket); - var newComment = TicketComment.Create(newTicket.Id, userId, command.Justification); - dbContext.TicketComments.Add(newComment); - return Result.Success((newTicket, newComment)); - }, - cancellationToken - ); - if (!claimed.IsSuccess) - return Result.Failure(claimed.Error); - - var (ticket, comment) = claimed.Value; - var userName = currentUser.UserName ?? userId.ToString(); - var roleDescription = - requestedRoleName - ?? $"новая роль «{command.NewRoleName}» (конфигов: {command.NewRoleMaxConfigs}, IP: {command.NewRoleMaxIpLimit})"; - - await notifier.NotifyTicketCreatedAsync( - ticket.Id, - userId, - userName, - ticket.Type, - cancellationToken - ); - await telegramNotifier.NotifyAdminsRoleRequestCreatedAsync( - ticket.Id, - userName, - roleDescription, - command.Justification, - cancellationToken - ); - - var commentDto = new TicketCommentDto( - comment.Id, - userId, - userName, - comment.Body, - comment.CreatedAt, - [] - ); - - var dto = new TicketDetailDto( - ticket.Id, - ticket.UserId, - userName, - ticket.Type, - ticket.Status, - ticket.RequestedRoleId, - requestedRoleName, - ticket.ProposedRoleName, - ticket.ProposedMaxConfigs, - ticket.ProposedMaxIpLimit, - ticket.RequestedDays, - ticket.CreatedAt, - [commentDto] - ); - - return Result.Success(dto); - } -} diff --git a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandValidator.cs b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandValidator.cs deleted file mode 100644 index 7003f54..0000000 --- a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandValidator.cs +++ /dev/null @@ -1,39 +0,0 @@ -using FluentValidation; - -namespace PnvPanel.Application.Support.CreateRoleRequest; - -public sealed class CreateRoleRequestTicketCommandValidator - : AbstractValidator -{ - public CreateRoleRequestTicketCommandValidator() - { - RuleFor(x => x.Justification).NotEmpty().MaximumLength(4000); - - RuleFor(x => x) - .Must(HaveExactlyOnePayload) - .WithMessage( - "Укажите либо существующую роль, либо параметры новой (не оба варианта и не ни одного)." - ); - - When( - x => x.ExistingRoleId is null, - () => - { - RuleFor(x => x.NewRoleName).NotEmpty().MaximumLength(100); - RuleFor(x => x.NewRoleMaxConfigs).NotNull().GreaterThanOrEqualTo(-1); - RuleFor(x => x.NewRoleMaxIpLimit).NotNull().GreaterThanOrEqualTo(-1); - } - ); - } - - private static bool HaveExactlyOnePayload(CreateRoleRequestTicketCommand command) - { - var hasExisting = command.ExistingRoleId is not null; - var hasNew = - !string.IsNullOrWhiteSpace(command.NewRoleName) - && command.NewRoleMaxConfigs is not null - && command.NewRoleMaxIpLimit is not null; - - return hasExisting ^ hasNew; - } -} diff --git a/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQueryHandler.cs b/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQueryHandler.cs index 0d23e2f..c712ef4 100644 --- a/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQueryHandler.cs @@ -9,7 +9,6 @@ namespace PnvPanel.Application.Support.GetTicket; public sealed class GetTicketQueryHandler( IAppDbContext dbContext, IIdentityService identityService, - IRoleService roleService, ICurrentUser currentUser ) : IQueryHandler> { @@ -33,7 +32,6 @@ public sealed class GetTicketQueryHandler( var dto = await TicketMapping.ToDetailDtoAsync( dbContext, identityService, - roleService, ticket, cancellationToken ); diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs deleted file mode 100644 index 4e2c212..0000000 --- a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs +++ /dev/null @@ -1,11 +0,0 @@ -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; - -namespace PnvPanel.Application.Support.ListSelectableRoles; - -/// Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery -/// (Admin/Roles), доступен любому активированному пользователю. -public sealed record ListSelectableRolesQuery - : IQuery>>, - IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs deleted file mode 100644 index e910896..0000000 --- a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs +++ /dev/null @@ -1,37 +0,0 @@ -using PnvPanel.Application.Auth; -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; - -namespace PnvPanel.Application.Support.ListSelectableRoles; - -public sealed class ListSelectableRolesQueryHandler( - IRoleService roleService, - IIdentityService identityService, - ICurrentUser currentUser -) : IQueryHandler>> -{ - // Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в - // CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure). - private const string AdminRoleName = "admin"; - - public async Task>> Handle( - ListSelectableRolesQuery query, - CancellationToken cancellationToken - ) - { - if (currentUser.UserId is not { } userId) - return Result.Failure>(AuthErrors.Unauthorized); - - var profile = await identityService.GetProfileAsync(userId, cancellationToken); - var roles = await roleService.ListRolesAsync(cancellationToken); - - // Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать). - var selectable = roles - .Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase)) - .Where(r => profile is null || r.Id != profile.RoleId) - .ToList(); - - return Result.Success>(selectable); - } -} diff --git a/backend/src/PnvPanel.Application/Support/SupportErrors.cs b/backend/src/PnvPanel.Application/Support/SupportErrors.cs index 131182a..c2479de 100644 --- a/backend/src/PnvPanel.Application/Support/SupportErrors.cs +++ b/backend/src/PnvPanel.Application/Support/SupportErrors.cs @@ -9,15 +9,6 @@ public static class SupportErrors "Support.AttachmentNotFound", "Вложение не найдено." ); - public static readonly Error RoleNotFound = Error.NotFound( - "Support.RoleNotFound", - "Роль не найдена." - ); - - public static readonly Error CannotRequestAdminRole = Error.Forbidden( - "Support.CannotRequestAdminRole", - "Роль администратора нельзя запросить через заявку." - ); public static readonly Error TicketClosed = Error.Conflict( "Support.TicketClosed", @@ -38,34 +29,24 @@ public static class SupportErrors "Переоткрыть можно только решённый тикет." ); - /// Заявка на роль/продление решается только через approve/reject — там же выполняется - /// сама выдача роли/дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего - /// не выдав пользователю. + /// Заявка на продление решается только через approve/reject — там же выполняется сама + /// выдача дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего не выдав + /// пользователю. public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation( "Support.OnlyBugReportCanBeResolvedDirectly", - "Заявку на роль или продление можно только одобрить/отклонить, а не решить напрямую." + "Заявку на продление можно только одобрить/отклонить, а не решить напрямую." ); public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation( "Support.OnlyBugReportCanBeClosedDirectly", - "Заявку на роль или продление можно только одобрить/отклонить, а не закрыть напрямую." + "Заявку на продление можно только одобрить/отклонить, а не закрыть напрямую." ); - /// Заявка на роль/продление одноразовая: повторное одобрение начислило бы дни/роль ещё - /// раз. Новый запрос — новый тикет, не переоткрытие старого. + /// Заявка на продление одноразовая: повторное одобрение начислило бы дни ещё раз. Новый + /// запрос — новый тикет, не переоткрытие старого. public static readonly Error OnlyBugReportCanBeReopened = Error.Validation( "Support.OnlyBugReportCanBeReopened", - "Заявку на роль или продление нельзя переоткрыть — оформите новую." - ); - - public static readonly Error RoleRequestAlreadyPending = Error.Conflict( - "Support.RoleRequestAlreadyPending", - "У вас уже есть необработанная заявка на роль." - ); - - public static readonly Error NotRoleRequest = Error.Validation( - "Support.NotRoleRequest", - "Это не заявка на роль." + "Заявку на продление нельзя переоткрыть — оформите новую." ); public static readonly Error ExtensionRequestAlreadyPending = Error.Conflict( diff --git a/backend/src/PnvPanel.Application/Support/TicketAuthorization.cs b/backend/src/PnvPanel.Application/Support/TicketAuthorization.cs index 79d27d8..c94f7e6 100644 --- a/backend/src/PnvPanel.Application/Support/TicketAuthorization.cs +++ b/backend/src/PnvPanel.Application/Support/TicketAuthorization.cs @@ -4,8 +4,8 @@ namespace PnvPanel.Application.Support; internal static class TicketAuthorization { - // Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в - // CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure). + // Совпадает со значением Infrastructure.Identity.RoleNames.Admin (Application не может + // ссылаться на Infrastructure). private const string AdminRoleName = "admin"; public static bool IsAdmin(CurrentUserProfile? profile) => diff --git a/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs b/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs index 108c0b7..842adca 100644 --- a/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs +++ b/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs @@ -3,8 +3,6 @@ using PnvPanel.Domain.Support; namespace PnvPanel.Application.Support; /// -/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером, -/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName. /// RequestedDays — только для ExtensionRequest (продление оплаченного периода). /// public sealed record TicketDetailDto( @@ -13,11 +11,6 @@ public sealed record TicketDetailDto( string UserName, TicketType Type, TicketStatus Status, - Guid? RequestedRoleId, - string? RequestedRoleName, - string? ProposedRoleName, - int? ProposedMaxConfigs, - int? ProposedMaxIpLimit, int? RequestedDays, DateTimeOffset CreatedAt, IReadOnlyList Comments diff --git a/backend/src/PnvPanel.Application/Support/TicketMapping.cs b/backend/src/PnvPanel.Application/Support/TicketMapping.cs index 2450bc7..dfabef4 100644 --- a/backend/src/PnvPanel.Application/Support/TicketMapping.cs +++ b/backend/src/PnvPanel.Application/Support/TicketMapping.cs @@ -15,7 +15,6 @@ internal static class TicketMapping public static async Task ToDetailDtoAsync( IAppDbContext dbContext, IIdentityService identityService, - IRoleService roleService, SupportTicket ticket, CancellationToken cancellationToken ) @@ -39,13 +38,6 @@ internal static class TicketMapping var userIds = comments.Select(c => c.AuthorId).Append(ticket.UserId).Distinct().ToList(); var userNames = await identityService.GetUserNamesAsync(userIds, cancellationToken); - string? requestedRoleName = null; - if (ticket.RequestedRoleId is { } roleId) - { - var roles = await roleService.ListRolesAsync(cancellationToken); - requestedRoleName = roles.FirstOrDefault(r => r.Id == roleId)?.Name; - } - var commentDtos = comments .Select(c => new TicketCommentDto( c.Id, @@ -71,11 +63,6 @@ internal static class TicketMapping userNames.GetValueOrDefault(ticket.UserId, "?"), ticket.Type, ticket.Status, - ticket.RequestedRoleId, - requestedRoleName, - ticket.ProposedRoleName, - ticket.ProposedMaxConfigs, - ticket.ProposedMaxIpLimit, ticket.RequestedDays, ticket.CreatedAt, commentDtos diff --git a/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs b/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs index 87c04ed..92e16a9 100644 --- a/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs +++ b/backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs @@ -5,14 +5,14 @@ namespace PnvPanel.Domain.Billing; /// /// Заявка пользователя на оплату. Kind.Subscription — оплата подписки за период (квартал/полгода/год, -/// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.RoleChangeTopUp — доплата разницы -/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет -/// BillingPaidUntil — см. RoleChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма +/// Period задан), продлевает BillingPaidUntil при подтверждении. Kind.PlanChangeTopUp — доплата разницы +/// в цене при увеличении тарифа с активным оплаченным периодом (Period не задан), подтверждение НЕ меняет +/// BillingPaidUntil — см. PlanChangeTopUp.Compute и ConfirmPaymentRequestCommandHandler. Сумма /// замораживается на момент создания (по действовавшей на тот момент ставке PricingSettings) — /// последующее изменение прайса админом не меняет уже созданные заявки. Не более одной активной /// (AwaitingPayment/AwaitingConfirmation) заявки Kind.Subscription на пользователя — инвариант -/// проверяется на уровне Application; RoleChangeTopUp создаётся системой при одобрении заявки на роль -/// и этим инвариантом не ограничен. +/// проверяется на уровне Application; PlanChangeTopUp создаётся системой при самостоятельной смене +/// тарифа и этим инвариантом не ограничен. /// public sealed class PaymentRequest : Entity { @@ -42,13 +42,13 @@ public sealed class PaymentRequest : Entity }; } - public static PaymentRequest CreateRoleChangeTopUp(Guid userId, int amountSnapshot) + public static PaymentRequest CreatePlanChangeTopUp(Guid userId, int amountSnapshot) { return new PaymentRequest { Id = Guid.NewGuid(), UserId = userId, - Kind = PaymentRequestKind.RoleChangeTopUp, + Kind = PaymentRequestKind.PlanChangeTopUp, Period = null, AmountSnapshot = amountSnapshot, Status = PaymentRequestStatus.AwaitingPayment, diff --git a/backend/src/PnvPanel.Domain/Billing/PaymentRequestKind.cs b/backend/src/PnvPanel.Domain/Billing/PaymentRequestKind.cs index c4b37fa..cab6b61 100644 --- a/backend/src/PnvPanel.Domain/Billing/PaymentRequestKind.cs +++ b/backend/src/PnvPanel.Domain/Billing/PaymentRequestKind.cs @@ -1,10 +1,10 @@ namespace PnvPanel.Domain.Billing; -/// Subscription — обычная оплата за период (Period задан). RoleChangeTopUp — доплата разницы -/// в цене при апгрейде роли с активным оплаченным периодом (Period не задан, не продлевает -/// BillingPaidUntil) — см. RoleChangeTopUp.Compute. +/// Subscription — обычная оплата за период (Period задан). PlanChangeTopUp — доплата разницы +/// в цене при увеличении тарифа с активным оплаченным периодом (Period не задан, не продлевает +/// BillingPaidUntil) — см. PlanChangeTopUp.Compute. public enum PaymentRequestKind { Subscription, - RoleChangeTopUp, + PlanChangeTopUp, } diff --git a/backend/src/PnvPanel.Domain/Billing/PlanChangeTopUp.cs b/backend/src/PnvPanel.Domain/Billing/PlanChangeTopUp.cs new file mode 100644 index 0000000..b775077 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Billing/PlanChangeTopUp.cs @@ -0,0 +1,54 @@ +using PnvPanel.Domain.Pricing; + +namespace PnvPanel.Domain.Billing; + +/// +/// Проратированная доплата при увеличении тарифа (Plan/кастомное количество конфигов) с активным +/// оплаченным периодом: пользователь платит разницу в месячной стоимости между старым и новым +/// количеством конфигов за оставшиеся дни BillingPaidUntil, а не выкупает период заново — так +/// больший тариф не достаётся бесплатно до конца уже оплаченного периода. Базовая ставка — +/// PricePerConfigPerQuarter (минимальный/справочный тариф), с той же скидочной лесенкой +/// (PricingDiscountTier), что и обычная оплата — см. domain-model.md#planchangetopup. +/// +public static class PlanChangeTopUp +{ + private const int DaysPerMonth = 30; + + /// Null, если доплата не нужна: тариф не подорожал, оплаченный период уже истёк, или + /// у старого/нового количества конфигов нет квоты (unlimited — цена для неё не считается). + public static int? Compute( + int pricePerConfigPerMonth, + int oldConfigCount, + int newConfigCount, + IReadOnlyCollection discountTiers, + DateTimeOffset paidUntil, + DateTimeOffset now + ) + { + if (oldConfigCount < 0 || newConfigCount < 0) + return null; + + var remainingDays = (paidUntil - now).TotalDays; + if (remainingDays <= 0) + return null; + + var oldMonthly = MonthlyTotal(pricePerConfigPerMonth, oldConfigCount, discountTiers); + var newMonthly = MonthlyTotal(pricePerConfigPerMonth, newConfigCount, discountTiers); + if (newMonthly <= oldMonthly) + return null; + + var amount = (newMonthly - oldMonthly) / (decimal)DaysPerMonth * (decimal)remainingDays; + return (int)Math.Round(amount, MidpointRounding.AwayFromZero); + } + + private static int MonthlyTotal( + int pricePerConfigPerMonth, + int configCount, + IReadOnlyCollection tiers + ) + { + var raw = pricePerConfigPerMonth * configCount; + var discountPercent = PricingDiscount.ResolvePercent(tiers, configCount); + return PricingDiscount.Apply(raw, discountPercent); + } +} diff --git a/backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs b/backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs deleted file mode 100644 index f17dfb2..0000000 --- a/backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs +++ /dev/null @@ -1,53 +0,0 @@ -using PnvPanel.Domain.Pricing; - -namespace PnvPanel.Domain.Billing; - -/// -/// Проратированная доплата при апгрейде роли с активным оплаченным периодом: пользователь платит -/// разницу в месячной стоимости между старой и новой ролью за оставшиеся дни BillingPaidUntil, а не -/// выкупает период заново — так более выгодная роль не достаётся бесплатно до конца уже оплаченного -/// периода. Базовая ставка — PricePerConfigPerQuarter (минимальный/справочный тариф), с той же -/// скидочной лесенкой (PricingDiscountTier), что и обычная оплата — см. domain-model.md#rolechangetopup. -/// -public static class RoleChangeTopUp -{ - private const int DaysPerMonth = 30; - - /// Null, если доплата не нужна: роль не подорожала, оплаченный период уже истёк, или - /// у старой/новой роли нет квоты (unlimited — цена для неё не считается). - public static int? Compute( - int pricePerConfigPerMonth, - int oldMaxConfigs, - int newMaxConfigs, - IReadOnlyCollection discountTiers, - DateTimeOffset paidUntil, - DateTimeOffset now - ) - { - if (oldMaxConfigs < 0 || newMaxConfigs < 0) - return null; - - var remainingDays = (paidUntil - now).TotalDays; - if (remainingDays <= 0) - return null; - - var oldMonthly = MonthlyTotal(pricePerConfigPerMonth, oldMaxConfigs, discountTiers); - var newMonthly = MonthlyTotal(pricePerConfigPerMonth, newMaxConfigs, discountTiers); - if (newMonthly <= oldMonthly) - return null; - - var amount = (newMonthly - oldMonthly) / (decimal)DaysPerMonth * (decimal)remainingDays; - return (int)Math.Round(amount, MidpointRounding.AwayFromZero); - } - - private static int MonthlyTotal( - int pricePerConfigPerMonth, - int maxConfigs, - IReadOnlyCollection tiers - ) - { - var raw = pricePerConfigPerMonth * maxConfigs; - var discountPercent = PricingDiscount.ResolvePercent(tiers, maxConfigs); - return PricingDiscount.Apply(raw, discountPercent); - } -} diff --git a/backend/src/PnvPanel.Domain/Plans/Plan.cs b/backend/src/PnvPanel.Domain/Plans/Plan.cs new file mode 100644 index 0000000..d0ce3fa --- /dev/null +++ b/backend/src/PnvPanel.Domain/Plans/Plan.cs @@ -0,0 +1,36 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Plans; + +/// Тариф — каталог квот конфигов, из которого пользователь сам выбирает себе квоту без +/// подтверждения админа (см. ChangePlanCommand). Ведёт админ. Не поддерживает "безлимит" — это +/// доступно только через прямой оверрайд AppUser.ConfigQuota админом (AdminSetUserPlanCommand). +public sealed class Plan : Entity +{ + public string Name { get; private set; } = string.Empty; + public int ConfigCount { get; private set; } + public int SortOrder { get; private set; } + public bool IsEnabled { get; private set; } + + private Plan() { } + + public static Plan Create(string name, int configCount, int sortOrder) + { + return new Plan + { + Id = Guid.NewGuid(), + Name = name, + ConfigCount = configCount, + SortOrder = sortOrder, + IsEnabled = true, + }; + } + + public void Update(string name, int configCount, int sortOrder, bool isEnabled) + { + Name = name; + ConfigCount = configCount; + SortOrder = sortOrder; + IsEnabled = isEnabled; + } +} diff --git a/backend/src/PnvPanel.Domain/Support/SupportTicket.cs b/backend/src/PnvPanel.Domain/Support/SupportTicket.cs index 20a32e7..1c169dd 100644 --- a/backend/src/PnvPanel.Domain/Support/SupportTicket.cs +++ b/backend/src/PnvPanel.Domain/Support/SupportTicket.cs @@ -4,22 +4,17 @@ using PnvPanel.Domain.Exceptions; namespace PnvPanel.Domain.Support; /// -/// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на роль (существующая роль -/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не -/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig). -/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками, -/// а не runtime-проверкой одного универсального конструктора. Для ExtensionRequest (продление -/// оплаченного периода — только для billing-ролей, см. AppRole.BillingEnabled) заполнен RequestedDays. +/// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на продление оплаченного +/// периода. Текст обращения и переписка — в TicketComment, отдельной таблицей (не навигационная +/// коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig). Для +/// ExtensionRequest (продление оплаченного периода — только для billing-ролей, см. +/// AppRole.BillingEnabled) заполнен RequestedDays. /// public sealed class SupportTicket : Entity { public Guid UserId { get; private set; } public TicketType Type { get; private set; } public TicketStatus Status { get; private set; } - public Guid? RequestedRoleId { get; private set; } - public string? ProposedRoleName { get; private set; } - public int? ProposedMaxConfigs { get; private set; } - public int? ProposedMaxIpLimit { get; private set; } public int? RequestedDays { get; private set; } public DateTimeOffset CreatedAt { get; private set; } @@ -37,39 +32,6 @@ public sealed class SupportTicket : Entity }; } - public static SupportTicket CreateRoleRequestForExistingRole(Guid userId, Guid roleId) - { - return new SupportTicket - { - Id = Guid.NewGuid(), - UserId = userId, - Type = TicketType.RoleRequest, - Status = TicketStatus.Open, - RequestedRoleId = roleId, - CreatedAt = DateTimeOffset.UtcNow, - }; - } - - public static SupportTicket CreateRoleRequestForNewRole( - Guid userId, - string name, - int maxConfigs, - int maxIpLimit - ) - { - return new SupportTicket - { - Id = Guid.NewGuid(), - UserId = userId, - Type = TicketType.RoleRequest, - Status = TicketStatus.Open, - ProposedRoleName = name, - ProposedMaxConfigs = maxConfigs, - ProposedMaxIpLimit = maxIpLimit, - CreatedAt = DateTimeOffset.UtcNow, - }; - } - public static SupportTicket CreateExtensionRequest(Guid userId, int requestedDays) { return new SupportTicket @@ -83,7 +45,7 @@ public sealed class SupportTicket : Entity }; } - /// Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application). + /// Решено (в т.ч. заявка на продление одобрена — дни начисляются оркестрацией на уровне Application). public void Resolve() { if (Status != TicketStatus.Open) @@ -92,7 +54,7 @@ public sealed class SupportTicket : Entity Status = TicketStatus.Resolved; } - /// Финальное состояние — обратного пути нет (в т.ч. заявка на роль отклонена). + /// Финальное состояние — обратного пути нет (в т.ч. заявка на продление отклонена). public void Close() { if (Status == TicketStatus.Closed) diff --git a/backend/src/PnvPanel.Domain/Support/TicketType.cs b/backend/src/PnvPanel.Domain/Support/TicketType.cs index 78dac3c..204195c 100644 --- a/backend/src/PnvPanel.Domain/Support/TicketType.cs +++ b/backend/src/PnvPanel.Domain/Support/TicketType.cs @@ -3,6 +3,5 @@ namespace PnvPanel.Domain.Support; public enum TicketType { BugReport, - RoleRequest, ExtensionRequest, } diff --git a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs index b23064f..a5bb7d3 100644 --- a/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs +++ b/backend/src/PnvPanel.Infrastructure/BackgroundJobs/BillingService.cs @@ -60,8 +60,8 @@ public sealed class BillingService( // отклонит, но и не просто "ничего не делаем": держим клиента рабочим на панели (Xray // проверяет expiryTime сам, независимо от нашего статуса) — см. // ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler и - // BillingConfigResumer.ProtectPendingConfigsAsync. RoleChangeTopUp намеренно не учитывается - // здесь — это доплата за апгрейд роли, а не оплата подписки, её ожидание не должно + // BillingConfigResumer.ProtectPendingConfigsAsync. PlanChangeTopUp намеренно не учитывается + // здесь — это доплата за увеличение тарифа, а не оплата подписки, её ожидание не должно // спасать от приостановки за реально просроченную подписку. var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync( r => diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs index 6577a93..9ed647e 100644 --- a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -7,11 +7,13 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Plans; using PnvPanel.Infrastructure.Apps; using PnvPanel.Infrastructure.BackgroundJobs; using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Instructions; using PnvPanel.Infrastructure.Persistence; +using PnvPanel.Infrastructure.Plans; using PnvPanel.Infrastructure.Pricing; using PnvPanel.Infrastructure.Security; using PnvPanel.Infrastructure.Storage; @@ -68,6 +70,7 @@ public static class DependencyInjection configuration.GetSection(AdminSeedOptions.SectionName) ); services.Configure(configuration.GetSection(RolesOptions.SectionName)); + services.Configure(configuration.GetSection(PlansOptions.SectionName)); var jwtOptions = configuration.GetSection(JwtOptions.SectionName).Get() @@ -141,6 +144,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как // ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService). services.AddScoped(); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs index e423937..54c3454 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs @@ -3,17 +3,15 @@ using PnvPanel.Application.Common.Models; namespace PnvPanel.Infrastructure.Identity; -/// Роль с квотой на число конфигов и лимитом одновременных IP на клиента. У пользователя ровно одна роль. +/// Роль с лимитом одновременных IP на клиента, доступом к инбаундам и флагом биллинга. +/// Квота конфигов больше не на роли — см. AppUser.ConfigQuota/Plan. У пользователя ровно одна роль. public class AppRole : IdentityRole { - public const int UnlimitedMaxConfigs = RoleQuota.Unlimited; public const int UnlimitedMaxIpLimit = RoleQuota.Unlimited; - public int MaxConfigs { get; set; } - /// Лимит одновременных IP на клиента в 3x-ui (`limitIp`); -1 — без лимита. Применяется /// только к новым клиентам, создаваемым в 3x-ui (см. IXuiPanelGateway.AddClientAsync) — при смене - /// роли/лимита существующие клиенты в панели не трогаются (как и квота MaxConfigs). + /// роли/лимита существующие клиенты в панели не трогаются (как и квота ConfigQuota). public int MaxIpLimit { get; set; } public bool IsSystem { get; set; } diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs index eef1616..d1292cd 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs @@ -15,6 +15,15 @@ public class AppUser : IdentityUser /// Блокировка админом: вход запрещён, все конфиги отключаются в 3x-ui (см. BlockUserCommandHandler). public bool IsBlocked { get; set; } + /// Фактическая квота активных конфигов (замена AppRole.MaxConfigs); -1 — без лимита + /// (используется для admin). Меняется через ChangePlanCommand (самообслуживание) или + /// AdminSetUserPlanCommand. + public int ConfigQuota { get; set; } + + /// Какой каталожный Plan выбран последним; null, если квота задана вручную (кастомное + /// число) или прямым оверрайдом админа. Обычная колонка без FK — по конвенции проекта. + public Guid? PlanId { get; set; } + /// Секрет для агрегированной подписки /sub/{token} (все активные конфиги пользователя). public string SubscriptionToken { get; set; } = string.Empty; diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs index 775843f..4b1bc2a 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; namespace PnvPanel.Infrastructure.Identity; @@ -13,6 +14,7 @@ public sealed class DbInitializer( IClientAppCatalogSeeder clientAppCatalogSeeder, IInstructionIntroSeeder instructionIntroSeeder, IPricingSettingsSeeder pricingSettingsSeeder, + IPlanSeeder planSeeder, IOptions adminSeedOptions, IOptions rolesOptions, ILogger logger @@ -20,15 +22,9 @@ public sealed class DbInitializer( { public async Task SeedAsync(CancellationToken cancellationToken = default) { - await EnsureRoleAsync( - RoleNames.Admin, - AppRole.UnlimitedMaxConfigs, - AppRole.UnlimitedMaxIpLimit, - isSystem: true - ); + await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxIpLimit, isSystem: true); await EnsureRoleAsync( RoleNames.User, - rolesOptions.Value.DefaultUserMaxConfigs, rolesOptions.Value.DefaultUserMaxIpLimit, isSystem: true ); @@ -36,19 +32,15 @@ public sealed class DbInitializer( await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken); await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken); await pricingSettingsSeeder.SeedIfEmptyAsync(cancellationToken); + await planSeeder.SeedIfEmptyAsync(cancellationToken); } - private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem) + private async Task EnsureRoleAsync(string name, int maxIpLimit, bool isSystem) { if (await roleManager.RoleExistsAsync(name)) return; - var role = new AppRole(name) - { - MaxConfigs = maxConfigs, - MaxIpLimit = maxIpLimit, - IsSystem = isSystem, - }; + var role = new AppRole(name) { MaxIpLimit = maxIpLimit, IsSystem = isSystem }; var result = await roleManager.CreateAsync(role); if (!result.Succeeded) { @@ -82,6 +74,7 @@ public sealed class DbInitializer( UserName = options.Username, IsActivated = true, ActivatedAt = DateTimeOffset.UtcNow, + ConfigQuota = RoleQuota.Unlimited, SubscriptionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)), }; diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs index b51d34b..b867455 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Models; @@ -12,7 +13,8 @@ internal sealed class IdentityService( UserManager userManager, SignInManager signInManager, RoleManager roleManager, - AppDbContext dbContext + AppDbContext dbContext, + IOptions rolesOptions ) : IIdentityService { public async Task> CreateUserAsync( @@ -25,6 +27,7 @@ internal sealed class IdentityService( { UserName = userName, IsActivated = false, + ConfigQuota = rolesOptions.Value.DefaultUserMaxConfigs, SubscriptionToken = GenerateSubscriptionToken(), }; var createResult = await userManager.CreateAsync(user, password); @@ -91,7 +94,8 @@ internal sealed class IdentityService( role.Name!, user.IsActivated, user.IsBlocked, - role.MaxConfigs, + user.ConfigQuota, + user.PlanId, role.MaxIpLimit, user.SubscriptionToken, role.BillingEnabled, @@ -492,6 +496,23 @@ internal sealed class IdentityService( return Result.Success(); } + public async Task SetConfigQuotaAsync( + Guid userId, + int configQuota, + Guid? planId, + CancellationToken cancellationToken + ) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + user.ConfigQuota = configQuota; + user.PlanId = planId; + 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 5d42194..26db6d1 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using PnvPanel.Application.Admin.Roles; using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Common.Interfaces; @@ -11,12 +12,12 @@ namespace PnvPanel.Infrastructure.Identity; internal sealed class RoleService( RoleManager roleManager, UserManager userManager, - IAppDbContext dbContext + IAppDbContext dbContext, + IOptions rolesOptions ) : IRoleService { public async Task> CreateRoleAsync( string name, - int maxConfigs, int maxIpLimit, bool billingEnabled, CancellationToken cancellationToken @@ -30,7 +31,6 @@ internal sealed class RoleService( var role = new AppRole(name) { - MaxConfigs = maxConfigs, MaxIpLimit = maxIpLimit, IsSystem = false, BillingEnabled = billingEnabled, @@ -51,7 +51,6 @@ internal sealed class RoleService( public async Task> UpdateRoleAsync( Guid roleId, - int maxConfigs, int maxIpLimit, bool billingEnabled, CancellationToken cancellationToken @@ -69,7 +68,6 @@ internal sealed class RoleService( var billingJustEnabled = billingEnabled && !role.BillingEnabled; - role.MaxConfigs = maxConfigs; role.MaxIpLimit = maxIpLimit; role.BillingEnabled = billingEnabled; await roleManager.UpdateAsync(role); @@ -137,14 +135,7 @@ 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, - r.BillingEnabled - )) + .Select(r => new RoleDto(r.Id, r.Name!, r.MaxIpLimit, r.IsSystem, r.BillingEnabled)) .ToListAsync(cancellationToken); } @@ -178,6 +169,22 @@ internal sealed class RoleService( await userManager.AddToRoleAsync(user, role.Name!); + // Безлимитная квота конфигов (-1) зарезервирована за admin (см. domain-model.md#approle) — + // выдаём её автоматически при назначении admin и снимаем при уходе с admin, иначе бывший + // админ молча остался бы с безлимитом навсегда. + if (staysAdmin && !wasAdmin) + { + user.ConfigQuota = RoleQuota.Unlimited; + user.PlanId = null; + await userManager.UpdateAsync(user); + } + else if (wasAdmin && !staysAdmin && user.ConfigQuota == RoleQuota.Unlimited) + { + user.ConfigQuota = rolesOptions.Value.DefaultUserMaxConfigs; + user.PlanId = null; + await userManager.UpdateAsync(user); + } + if (role.BillingEnabled) await InitializeBillingGraceAsync([user], cancellationToken); @@ -185,5 +192,5 @@ internal sealed class RoleService( } private static RoleDto ToDto(AppRole role) => - new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem, role.BillingEnabled); + new(role.Id, role.Name!, 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 ba4431c..e07fd07 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -10,6 +10,7 @@ using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Instructions; using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Plans; using PnvPanel.Domain.Pricing; using PnvPanel.Domain.Support; using PnvPanel.Domain.Telegram; @@ -65,6 +66,8 @@ public class AppDbContext(DbContextOptions options) public DbSet PaymentRequests => Set(); + public DbSet Plans => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PlanConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PlanConfiguration.cs new file mode 100644 index 0000000..f080058 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PlanConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Plans; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class PlanConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Plans"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(64); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/SupportTicketConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/SupportTicketConfiguration.cs index 83a6c83..0f29593 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/SupportTicketConfiguration.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/SupportTicketConfiguration.cs @@ -13,7 +13,6 @@ public class SupportTicketConfiguration : IEntityTypeConfiguration x.Type).HasConversion().HasMaxLength(32); builder.Property(x => x.Status).HasConversion().HasMaxLength(32); - builder.Property(x => x.ProposedRoleName).HasMaxLength(100); builder.HasIndex(x => new { x.UserId, x.Status }); builder.HasIndex(x => new { x.Type, x.Status }); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723184020_AddPlansAndUserConfigQuota.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723184020_AddPlansAndUserConfigQuota.Designer.cs new file mode 100644 index 0000000..03d1cec --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723184020_AddPlansAndUserConfigQuota.Designer.cs @@ -0,0 +1,1091 @@ +// +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("20260723184020_AddPlansAndUserConfigQuota")] + partial class AddPlansAndUserConfigQuota + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .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("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Period") + .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("NotifyOnStatusChange") + .HasColumnType("boolean"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Plans.Plan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigCount") + .HasColumnType("integer"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Plans", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DiscountPercent") + .HasColumnType("integer"); + + b.Property("MinConfigs") + .HasColumnType("integer"); + + b.Property("PricingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PricingSettingsId", "MinConfigs") + .IsUnique(); + + b.ToTable("PricingDiscountTiers", (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("RequestedDays") + .HasColumnType("integer"); + + 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("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("ConfigQuota") + .HasColumnType("integer"); + + 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("PlanId") + .HasColumnType("uuid"); + + 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/20260723184020_AddPlansAndUserConfigQuota.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723184020_AddPlansAndUserConfigQuota.cs new file mode 100644 index 0000000..430f9fb --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260723184020_AddPlansAndUserConfigQuota.cs @@ -0,0 +1,128 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPlansAndUserConfigQuota : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ProposedMaxConfigs", + table: "SupportTickets"); + + migrationBuilder.DropColumn( + name: "ProposedMaxIpLimit", + table: "SupportTickets"); + + migrationBuilder.DropColumn( + name: "ProposedRoleName", + table: "SupportTickets"); + + migrationBuilder.DropColumn( + name: "RequestedRoleId", + table: "SupportTickets"); + + migrationBuilder.AddColumn( + name: "ConfigQuota", + table: "AspNetUsers", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PlanId", + table: "AspNetUsers", + type: "uuid", + nullable: true); + + // Перенос текущей квоты роли на пользователя — до дропа AspNetRoles.MaxConfigs ниже, + // иначе исходные данные для переноса будут потеряны. У пользователя ровно одна роль + // (инвариант проекта) — джойн даёт ровно одну строку на юзера. + migrationBuilder.Sql( + """ + UPDATE "AspNetUsers" u + SET "ConfigQuota" = COALESCE(r."MaxConfigs", 3) + FROM "AspNetUserRoles" ur + JOIN "AspNetRoles" r ON r."Id" = ur."RoleId" + WHERE ur."UserId" = u."Id"; + """); + + // PaymentRequestKind.RoleChangeTopUp переименован в PlanChangeTopUp (хранится как строка). + migrationBuilder.Sql( + """ + UPDATE "PaymentRequests" SET "Kind" = 'PlanChangeTopUp' WHERE "Kind" = 'RoleChangeTopUp'; + """); + + migrationBuilder.DropColumn( + name: "MaxConfigs", + table: "AspNetRoles"); + + migrationBuilder.CreateTable( + name: "Plans", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ConfigCount = table.Column(type: "integer", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Plans", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Plans"); + + migrationBuilder.DropColumn( + name: "ConfigQuota", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "PlanId", + table: "AspNetUsers"); + + migrationBuilder.AddColumn( + name: "ProposedMaxConfigs", + table: "SupportTickets", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ProposedMaxIpLimit", + table: "SupportTickets", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ProposedRoleName", + table: "SupportTickets", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "RequestedRoleId", + table: "SupportTickets", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "MaxConfigs", + table: "AspNetRoles", + type: "integer", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 66cc427..94321f9 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -590,6 +590,31 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.ToTable("Nodes", (string)null); }); + modelBuilder.Entity("PnvPanel.Domain.Plans.Plan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigCount") + .HasColumnType("integer"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Plans", (string)null); + }); + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b => { b.Property("Id") @@ -645,22 +670,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations 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("RequestedDays") .HasColumnType("integer"); - b.Property("RequestedRoleId") - .HasColumnType("uuid"); - b.Property("Status") .IsRequired() .HasMaxLength(32) @@ -823,9 +835,6 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.Property("IsSystem") .HasColumnType("boolean"); - b.Property("MaxConfigs") - .HasColumnType("integer"); - b.Property("MaxIpLimit") .HasColumnType("integer"); @@ -874,6 +883,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .IsConcurrencyToken() .HasColumnType("text"); + b.Property("ConfigQuota") + .HasColumnType("integer"); + b.Property("Email") .HasMaxLength(256) .HasColumnType("character varying(256)"); @@ -910,6 +922,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.Property("PhoneNumberConfirmed") .HasColumnType("boolean"); + b.Property("PlanId") + .HasColumnType("uuid"); + b.Property("SecurityStamp") .HasColumnType("text"); diff --git a/backend/src/PnvPanel.Infrastructure/Plans/PlanSeeder.cs b/backend/src/PnvPanel.Infrastructure/Plans/PlanSeeder.cs new file mode 100644 index 0000000..1b80ef2 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Plans/PlanSeeder.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Plans; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.Plans; + +internal sealed class PlanSeeder(AppDbContext dbContext, ILogger logger) : IPlanSeeder +{ + public async Task SeedIfEmptyAsync(CancellationToken cancellationToken) + { + if (await dbContext.Plans.AnyAsync(cancellationToken)) + return; + + dbContext.Plans.AddRange( + Plan.Create("Стандарт", 3, 0), + Plan.Create("Плюс", 6, 1), + Plan.Create("Про", 9, 2) + ); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Seeded default plans"); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs index fe4a7ee..b9627eb 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ConfirmPaymentRequestCommandHandlerTests.cs @@ -31,7 +31,8 @@ public class ConfirmPaymentRequestCommandHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: 5, + ConfigQuota: 5, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: true, @@ -225,13 +226,13 @@ public class ConfirmPaymentRequestCommandHandlerTests } [Fact] - public async Task Handle_WhenRoleChangeTopUp_ConfirmsWithoutExtendingPaidUntil() + public async Task Handle_WhenPlanChangeTopUp_ConfirmsWithoutExtendingPaidUntil() { using var dbContext = InMemoryDbContextFactory.Create(); var adminId = Guid.NewGuid(); var userId = Guid.NewGuid(); var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20); - var request = PaymentRequest.CreateRoleChangeTopUp(userId, 1200); + var request = PaymentRequest.CreatePlanChangeTopUp(userId, 1200); request.MarkPaymentSent(); dbContext.PaymentRequests.Add(request); await dbContext.SaveChangesAsync(CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/GrantBillingGiftCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/GrantBillingGiftCommandHandlerTests.cs index fc6ec30..1042d7c 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/GrantBillingGiftCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/GrantBillingGiftCommandHandlerTests.cs @@ -28,7 +28,8 @@ public class GrantBillingGiftCommandHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: 5, + ConfigQuota: 5, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: billingEnabled, diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ListPaymentRequestsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ListPaymentRequestsQueryHandlerTests.cs index 5ae0ed7..7b380a6 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ListPaymentRequestsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/ListPaymentRequestsQueryHandlerTests.cs @@ -17,7 +17,7 @@ public class ListPaymentRequestsQueryHandlerTests using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); var subscription = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500); - var topUp = PaymentRequest.CreateRoleChangeTopUp(userId, 500); + var topUp = PaymentRequest.CreatePlanChangeTopUp(userId, 500); dbContext.PaymentRequests.AddRange(subscription, topUp); await dbContext.SaveChangesAsync(CancellationToken.None); @@ -30,7 +30,7 @@ public class ListPaymentRequestsQueryHandlerTests var result = await handler.Handle( new ListPaymentRequestsQuery( StatusFilter: null, - KindFilter: PaymentRequestKind.RoleChangeTopUp, + KindFilter: PaymentRequestKind.PlanChangeTopUp, Search: null, Page: 1, PageSize: 20 diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs index 7d90575..a2571df 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/RejectPaymentRequestCommandHandlerTests.cs @@ -42,7 +42,8 @@ public class RejectPaymentRequestCommandHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: 5, + ConfigQuota: 5, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: true, diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs index cca0d29..51246b7 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, BillingEnabled: false), - new(customRoleId, "premium", 10, 5, IsSystem: false, BillingEnabled: false), + new(adminRoleId, "admin", -1, IsSystem: true, BillingEnabled: false), + new(customRoleId, "premium", 5, IsSystem: false, BillingEnabled: false), } ); _roleService diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Plans/PlanCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Plans/PlanCommandHandlerTests.cs new file mode 100644 index 0000000..ec1389d --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Plans/PlanCommandHandlerTests.cs @@ -0,0 +1,129 @@ +using PnvPanel.Application.Admin.Plans; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Plans; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Plans; + +public class PlanCommandHandlerTests +{ + [Fact] + public async Task Create_AddsPlan() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new CreatePlanCommandHandler(dbContext); + + var result = await handler.Handle( + new CreatePlanCommand("Стандарт", 3, 0), + CancellationToken.None + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Стандарт", result.Value.Name); + Assert.Equal(3, result.Value.ConfigCount); + Assert.True(result.Value.IsEnabled); + Assert.NotNull(await dbContext.Plans.FindAsync([result.Value.Id], CancellationToken.None)); + } + + [Fact] + public async Task Update_WhenNotFound_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new UpdatePlanCommandHandler(dbContext); + + var result = await handler.Handle( + new UpdatePlanCommand(Guid.NewGuid(), "Плюс", 6, 1, true), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(PnvPanel.Application.Plans.PlanErrors.NotFound, result.Error); + } + + [Fact] + public async Task Update_WhenFound_UpdatesFields() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var plan = Plan.Create("Стандарт", 3, 0); + dbContext.Plans.Add(plan); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdatePlanCommandHandler(dbContext); + var result = await handler.Handle( + new UpdatePlanCommand(plan.Id, "Плюс", 6, 1, false), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal("Плюс", result.Value.Name); + Assert.Equal(6, result.Value.ConfigCount); + Assert.False(result.Value.IsEnabled); + } + + [Fact] + public async Task Delete_WhenNotFound_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new DeletePlanCommandHandler(dbContext); + + var result = await handler.Handle( + new DeletePlanCommand(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(PnvPanel.Application.Plans.PlanErrors.NotFound, result.Error); + } + + [Fact] + public async Task Delete_WhenFound_RemovesPlan() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var plan = Plan.Create("Стандарт", 3, 0); + dbContext.Plans.Add(plan); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new DeletePlanCommandHandler(dbContext); + var result = await handler.Handle(new DeletePlanCommand(plan.Id), CancellationToken.None); + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Null(await dbContext.Plans.FindAsync([plan.Id], CancellationToken.None)); + } + + [Fact] + public async Task ListAdmin_ReturnsAllPlansOrderedBySortOrder() + { + using var dbContext = InMemoryDbContextFactory.Create(); + dbContext.Plans.AddRange( + Plan.Create("Про", 9, 2), + Plan.Create("Стандарт", 3, 0), + Plan.Create("Плюс", 6, 1) + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new ListAdminPlansQueryHandler(dbContext); + var result = await handler.Handle(new ListAdminPlansQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(["Стандарт", "Плюс", "Про"], result.Value.Select(p => p.Name)); + } + + [Fact] + public async Task ListPublic_OnlyReturnsEnabledPlans() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var disabled = Plan.Create("Скрытый", 12, 3); + disabled.Update(disabled.Name, disabled.ConfigCount, disabled.SortOrder, isEnabled: false); + dbContext.Plans.AddRange(Plan.Create("Стандарт", 3, 0), disabled); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new PnvPanel.Application.Plans.ListPlansQueryHandler(dbContext); + var result = await handler.Handle(new PnvPanel.Application.Plans.ListPlansQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + Assert.Equal("Стандарт", result.Value[0].Name); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs index 7677d8b..87c03cf 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs @@ -31,7 +31,8 @@ public class ApproveExtensionRequestCommandHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: 5, + ConfigQuota: 5, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: true, diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs deleted file mode 100644 index 54f26de..0000000 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs +++ /dev/null @@ -1,265 +0,0 @@ -using NSubstitute; -using PnvPanel.Application.Admin.Roles; -using PnvPanel.Application.Admin.Support; -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Common.Messaging; -using PnvPanel.Application.Common.Models; -using PnvPanel.Application.Support; -using PnvPanel.Application.Tests.TestSupport; -using PnvPanel.Domain.Billing; -using PnvPanel.Domain.Support; -using Xunit; - -namespace PnvPanel.Application.Tests.Admin.Support; - -public class ApproveRoleRequestCommandHandlerTests -{ - private readonly IRoleService _roleService = Substitute.For(); - private readonly IIdentityService _identityService = Substitute.For(); - private readonly IRealtimeNotifier _notifier = Substitute.For(); - private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); - - private ApproveRoleRequestCommandHandler CreateHandler(IAppDbContext dbContext, ICurrentUser currentUser) => - new(dbContext, _roleService, _identityService, _notifier, _telegramNotifier, currentUser); - - private static CurrentUserProfile Profile( - Guid userId, - int maxConfigs, - DateTimeOffset? billingPaidUntil = null - ) => - new( - userId, - "alice", - Guid.NewGuid(), - "old-role", - IsActivated: true, - IsBlocked: false, - MaxConfigs: maxConfigs, - MaxIpLimit: 3, - SubscriptionToken: "sub-token", - BillingEnabled: billingPaidUntil != null, - BillingPaidUntil: billingPaidUntil, - BillingSuspended: false - ); - - [Fact] - public async Task Handle_ForNewRoleRequest_CreatesRoleAssignsAndResolves() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var userId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForNewRole(userId, "premium", 10, 5); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(CancellationToken.None); - - var newRoleId = Guid.NewGuid(); - _roleService - .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()); - - var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"); - var handler = CreateHandler(dbContext, currentUser); - - var result = await handler.Handle( - new ApproveRoleRequestCommand(ticket.Id), - CancellationToken.None - ); - - Assert.True(result.IsSuccess); - Assert.Equal(TicketStatus.Resolved, ticket.Status); - await _roleService - .Received(1) - .CreateRoleAsync("premium", 10, 5, false, Arg.Any()); - await _roleService - .Received(1) - .ChangeUserRoleAsync(userId, newRoleId, Arg.Any()); - await _telegramNotifier - .Received(1) - .NotifyUserAsync( - userId, - Arg.Any(), - Arg.Any(), - Arg.Any() - ); - } - - [Fact] - public async Task Handle_ForExistingRoleRequest_SkipsRoleCreation() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var userId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(CancellationToken.None); - - _roleService - .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "premium", 10, 5, false, false) }); - _roleService - .ChangeUserRoleAsync(userId, roleId, Arg.Any()) - .Returns(Result.Success()); - - var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid()); - var handler = CreateHandler(dbContext, currentUser); - - var result = await handler.Handle( - new ApproveRoleRequestCommand(ticket.Id), - CancellationToken.None - ); - - Assert.True(result.IsSuccess); - await _roleService - .DidNotReceive() - .CreateRoleAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any() - ); - } - - [Fact] - public async Task Handle_WhenNotRoleRequestType_ReturnsError() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var ticket = SupportTicket.CreateBugReport(Guid.NewGuid()); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(CancellationToken.None); - - var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid()); - var handler = CreateHandler(dbContext, currentUser); - - var result = await handler.Handle( - new ApproveRoleRequestCommand(ticket.Id), - CancellationToken.None - ); - - Assert.False(result.IsSuccess); - Assert.Equal(SupportErrors.NotRoleRequest, result.Error); - } - - [Fact] - public async Task Handle_WhenTicketIsOwnedByAdmin_StillApproves() - { - // Одобрить свою же заявку можно — единственный реальный риск (снять admin с последнего - // администратора) ловит RoleService.ChangeUserRoleAsync, а не этот хендлер (см. соседний тест). - using var dbContext = InMemoryDbContextFactory.Create(); - var adminId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(adminId, roleId); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(CancellationToken.None); - - _roleService - .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "premium", 10, 5, false, false) }); - _roleService - .ChangeUserRoleAsync(adminId, roleId, Arg.Any()) - .Returns(Result.Success()); - - var currentUser = FakeCurrentUser.Authenticated(adminId, "admin"); - var handler = CreateHandler(dbContext, currentUser); - - var result = await handler.Handle( - new ApproveRoleRequestCommand(ticket.Id), - CancellationToken.None - ); - - Assert.True(result.IsSuccess); - Assert.Equal(TicketStatus.Resolved, ticket.Status); - } - - [Fact] - public async Task Handle_WhenRoleServiceRefusesLastAdminDowngrade_PropagatesFailure() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var adminId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(adminId, roleId); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(CancellationToken.None); - - _roleService - .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "premium", 10, 5, false, false) }); - _roleService - .ChangeUserRoleAsync(adminId, roleId, Arg.Any()) - .Returns(Result.Failure(RoleErrors.CannotRemoveLastAdmin)); - - var currentUser = FakeCurrentUser.Authenticated(adminId, "admin"); - var handler = CreateHandler(dbContext, currentUser); - - var result = await handler.Handle( - new ApproveRoleRequestCommand(ticket.Id), - CancellationToken.None - ); - - Assert.False(result.IsSuccess); - Assert.Equal(RoleErrors.CannotRemoveLastAdmin, result.Error); - // Тикет остаётся Open — можно повторить попытку после назначения второго админа. - Assert.Equal(TicketStatus.Open, ticket.Status); - } - - [Fact] - public async Task Handle_WhenNewRoleMoreExpensiveWithActivePaidPeriod_CreatesRoleChangeTopUp() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var userId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId); - dbContext.SupportTickets.Add(ticket); - - var pricing = PnvPanel.Domain.Pricing.PricingSettings.CreateDefault(); - pricing.Update(500, 450, 400); - dbContext.PricingSettings.Add(pricing); - await dbContext.SaveChangesAsync(CancellationToken.None); - - _roleService - .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) }); - _roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any()).Returns(Result.Success()); - _identityService - .GetProfileAsync(userId, Arg.Any()) - .Returns(Profile(userId, maxConfigs: 3, billingPaidUntil: DateTimeOffset.UtcNow.AddDays(30))); - - var handler = CreateHandler(dbContext, FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")); - - var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - var topUp = Assert.Single(dbContext.PaymentRequests.Local); - Assert.Equal(PaymentRequestKind.RoleChangeTopUp, topUp.Kind); - Assert.Null(topUp.Period); - Assert.True(topUp.AmountSnapshot > 0); - } - - [Fact] - public async Task Handle_WhenNoActivePaidPeriod_DoesNotCreateTopUp() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var userId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(CancellationToken.None); - - _roleService - .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "premium", MaxConfigs: 10, 5, false, BillingEnabled: true) }); - _roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any()).Returns(Result.Success()); - _identityService - .GetProfileAsync(userId, Arg.Any()) - .Returns(Profile(userId, maxConfigs: 3, billingPaidUntil: null)); - - var handler = CreateHandler(dbContext, FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")); - - var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Empty(dbContext.PaymentRequests.Local); - } -} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/RejectRoleRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/RejectRoleRequestCommandHandlerTests.cs deleted file mode 100644 index 6882713..0000000 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/RejectRoleRequestCommandHandlerTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using NSubstitute; -using PnvPanel.Application.Admin.Support; -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Tests.TestSupport; -using PnvPanel.Domain.Support; -using Xunit; - -namespace PnvPanel.Application.Tests.Admin.Support; - -public class RejectRoleRequestCommandHandlerTests -{ - private readonly IRealtimeNotifier _notifier = Substitute.For(); - private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); - - [Fact] - public async Task Handle_RejectsRoleRequest_ClosesTicket() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var userId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, Guid.NewGuid()); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(CancellationToken.None); - - var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"); - var handler = new RejectRoleRequestCommandHandler( - dbContext, - _notifier, - _telegramNotifier, - currentUser - ); - - var result = await handler.Handle( - new RejectRoleRequestCommand(ticket.Id, "не подходит"), - CancellationToken.None - ); - - Assert.True(result.IsSuccess); - Assert.Equal(TicketStatus.Closed, ticket.Status); - } - - [Fact] - public async Task Handle_WhenTicketIsOwnedByAdmin_StillRejects() - { - // Отклонить свою же заявку можно — Reject не меняет роль, риска нет. - using var dbContext = InMemoryDbContextFactory.Create(); - var adminId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(adminId, Guid.NewGuid()); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(CancellationToken.None); - - var currentUser = FakeCurrentUser.Authenticated(adminId, "admin"); - var handler = new RejectRoleRequestCommandHandler( - dbContext, - _notifier, - _telegramNotifier, - currentUser - ); - - var result = await handler.Handle( - new RejectRoleRequestCommand(ticket.Id, null), - CancellationToken.None - ); - - Assert.True(result.IsSuccess); - Assert.Equal(TicketStatus.Closed, ticket.Status); - } -} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ResolveTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ResolveTicketCommandHandlerTests.cs index 8857cdc..43c46fb 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ResolveTicketCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ResolveTicketCommandHandlerTests.cs @@ -68,14 +68,13 @@ public class ResolveTicketCommandHandlerTests } [Fact] - public async Task Handle_WhenRoleRequest_ReturnsOnlyBugReportCanBeResolvedDirectly() + public async Task Handle_WhenExtensionRequest_ReturnsOnlyBugReportCanBeResolvedDirectly() { - // Заявку на роль/продление можно решить только через Approve/Reject — Resolve обходил бы - // одобрение (роль/продление так и не назначились бы), см. CLAUDE.md. + // Заявку на продление можно решить только через Approve/Reject — Resolve обходил бы + // одобрение (дни так и не начислились бы), см. CLAUDE.md. using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId); + var ticket = SupportTicket.CreateExtensionRequest(userId, 14); dbContext.SupportTickets.Add(ticket); await dbContext.SaveChangesAsync(CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/AdminSetUserPlanCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/AdminSetUserPlanCommandHandlerTests.cs new file mode 100644 index 0000000..7e91f66 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/AdminSetUserPlanCommandHandlerTests.cs @@ -0,0 +1,139 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Plans; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Plans; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Users; + +public class AdminSetUserPlanCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + + private static CurrentUserProfile Profile(Guid userId, string role, int configQuota) => + new( + userId, + "alice", + Guid.NewGuid(), + role, + IsActivated: true, + IsBlocked: false, + ConfigQuota: configQuota, + PlanId: null, + MaxIpLimit: 2, + SubscriptionToken: "sub-token", + BillingEnabled: false, + BillingPaidUntil: null, + BillingSuspended: false + ); + + [Fact] + public async Task Handle_WhenUnlimitedForNonAdmin_ReturnsUnlimitedOnlyForAdmin() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, "user", 3)); + + var handler = new AdminSetUserPlanCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(Guid.NewGuid()) + ); + + var result = await handler.Handle( + new AdminSetUserPlanCommand(userId, null, RoleQuota.Unlimited), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(PlanErrors.UnlimitedOnlyForAdmin, result.Error); + await _identityService + .DidNotReceive() + .SetConfigQuotaAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_WhenUnlimitedForAdmin_Succeeds() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, "admin", -1)); + _identityService + .SetConfigQuotaAsync(userId, RoleQuota.Unlimited, null, Arg.Any()) + .Returns(Result.Success()); + + var handler = new AdminSetUserPlanCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(Guid.NewGuid()) + ); + + var result = await handler.Handle( + new AdminSetUserPlanCommand(userId, null, RoleQuota.Unlimited), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + await _identityService + .Received(1) + .SetConfigQuotaAsync(userId, RoleQuota.Unlimited, null, Arg.Any()); + } + + [Fact] + public async Task Handle_WithPlanId_UsesPlanConfigCount() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var plan = Plan.Create("Плюс", 6, 0); + dbContext.Plans.Add(plan); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .SetConfigQuotaAsync(userId, 6, plan.Id, Arg.Any()) + .Returns(Result.Success()); + + var handler = new AdminSetUserPlanCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(Guid.NewGuid()) + ); + + var result = await handler.Handle( + new AdminSetUserPlanCommand(userId, plan.Id, null), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + await _identityService + .Received(1) + .SetConfigQuotaAsync(userId, 6, plan.Id, Arg.Any()); + Assert.Single(dbContext.AuditLogs.Local); + } + + [Fact] + public async Task Handle_WhenPlanNotFound_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + var handler = new AdminSetUserPlanCommandHandler( + dbContext, + _identityService, + FakeCurrentUser.Authenticated(Guid.NewGuid()) + ); + + var result = await handler.Handle( + new AdminSetUserPlanCommand(Guid.NewGuid(), Guid.NewGuid(), null), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(PlanErrors.NotFound, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs index 3dd50d7..9513a43 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/ListUsersQueryHandlerTests.cs @@ -56,13 +56,13 @@ public class ListUsersQueryHandlerTests } [Fact] - public async Task Handle_WhenPendingRequestIsRoleChangeTopUp_LeavesBillingPendingReviewFalse() + public async Task Handle_WhenPendingRequestIsPlanChangeTopUp_LeavesBillingPendingReviewFalse() { - // RoleChangeTopUp — доплата за смену роли, не подписка; не должна показывать пользователя + // PlanChangeTopUp — доплата за смену тарифа, не подписка; не должна показывать пользователя // как "оплата на проверке" в админке (см. CLAUDE.md про Kind). using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); - var request = PaymentRequest.CreateRoleChangeTopUp(userId, 500); + var request = PaymentRequest.CreatePlanChangeTopUp(userId, 500); request.MarkPaymentSent(); dbContext.PaymentRequests.Add(request); await dbContext.SaveChangesAsync(CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs index e1a960b..4d4209d 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/GetCurrentUserQueryHandlerTests.cs @@ -54,6 +54,7 @@ public class GetCurrentUserQueryHandlerTests true, false, 3, + null, RoleQuota.Unlimited, "sub-token", false, diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs index c515896..77cd77f 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/LoginCommandHandlerTests.cs @@ -29,7 +29,8 @@ public class LoginCommandHandlerTests "user", IsActivated: true, IsBlocked: false, - MaxConfigs: 3, + ConfigQuota: 3, + PlanId: null, MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "sub-token", BillingEnabled: false, diff --git a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs index e80e3bd..976eda9 100644 --- a/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Auth/RefreshCommandHandlerTests.cs @@ -28,7 +28,8 @@ public class RefreshCommandHandlerTests "user", IsActivated: true, IsBlocked: false, - MaxConfigs: 3, + ConfigQuota: 3, + PlanId: null, MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "sub-token", BillingEnabled: false, @@ -112,7 +113,8 @@ public class RefreshCommandHandlerTests "user", IsActivated: true, IsBlocked: true, - MaxConfigs: 3, + ConfigQuota: 3, + PlanId: null, MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "sub-token", BillingEnabled: false, diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs index 060ad1a..275acb3 100644 --- a/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Billing/CreatePaymentRequest/CreatePaymentRequestCommandHandlerTests.cs @@ -27,7 +27,8 @@ public class CreatePaymentRequestCommandHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: maxConfigs, + ConfigQuota: maxConfigs, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: billingEnabled, diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandlerTests.cs index 52f4e2f..8e4d6e4 100644 --- a/backend/tests/PnvPanel.Application.Tests/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Billing/GetMyBillingStatus/GetMyBillingStatusQueryHandlerTests.cs @@ -24,7 +24,8 @@ public class GetMyBillingStatusQueryHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: 5, + ConfigQuota: 5, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: billingEnabled, diff --git a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs index e25ba3a..7dbdb0c 100644 --- a/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Billing/MarkPaymentSent/MarkPaymentSentCommandHandlerTests.cs @@ -33,7 +33,8 @@ public class MarkPaymentSentCommandHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: 5, + ConfigQuota: 5, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: true, diff --git a/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs b/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs index d75495d..17bf3ac 100644 --- a/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Common/Behaviors/RequireActivationBehaviorTests.cs @@ -26,7 +26,8 @@ public class RequireActivationBehaviorTests "user", IsActivated: isActivated, IsBlocked: false, - MaxConfigs: 3, + ConfigQuota: 3, + PlanId: null, MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "sub-token", BillingEnabled: false, diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs index 16126da..70a623d 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Create/CreateVpnConfigCommandHandlerTests.cs @@ -33,7 +33,8 @@ public class CreateVpnConfigCommandHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: 5, + ConfigQuota: 5, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: billingEnabled, diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs index ac40875..e8dff5c 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs @@ -46,6 +46,7 @@ public class GetMyConfigsQueryHandlerTests true, false, 5, + null, RoleQuota.Unlimited, "sub-token", false, @@ -65,7 +66,7 @@ public class GetMyConfigsQueryHandlerTests Assert.True(result.IsSuccess); Assert.Single(result.Value.Configs); Assert.Equal(activeConfig.Id, result.Value.Configs[0].Id); - Assert.Equal(5, result.Value.MaxConfigs); + Assert.Equal(5, result.Value.ConfigQuota); } [Fact] diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs index dc74ab6..7a6b879 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs @@ -16,6 +16,7 @@ using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.Instructions; using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Plans; using PnvPanel.Domain.Pricing; using PnvPanel.Domain.Support; using PnvPanel.Domain.Telegram; @@ -48,6 +49,7 @@ public sealed class ThrowingSaveDbContext(AppDbContext inner) : IAppDbContext public DbSet PricingDiscountTiers => inner.PricingDiscountTiers; public DbSet BillingSettings => inner.BillingSettings; public DbSet PaymentRequests => inner.PaymentRequests; + public DbSet Plans => inner.Plans; public DatabaseFacade Database => inner.Database; public Task SaveChangesAsync(CancellationToken cancellationToken) => @@ -68,6 +70,7 @@ public class RotateVpnConfigCommandHandlerTests true, false, 3, + null, RoleQuota.Unlimited, "sub-token", false, diff --git a/backend/tests/PnvPanel.Application.Tests/Plans/ChangePlanCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Plans/ChangePlanCommandHandlerTests.cs new file mode 100644 index 0000000..3625142 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Plans/ChangePlanCommandHandlerTests.cs @@ -0,0 +1,298 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Plans; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Billing; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Plans; +using PnvPanel.Domain.Pricing; +using Xunit; + +namespace PnvPanel.Application.Tests.Plans; + +public class ChangePlanCommandHandlerTests +{ + 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 static CurrentUserProfile Profile( + Guid userId, + int configQuota = 3, + bool billingEnabled = false, + DateTimeOffset? billingPaidUntil = null + ) => + new( + userId, + "alice", + Guid.NewGuid(), + "user", + IsActivated: true, + IsBlocked: false, + ConfigQuota: configQuota, + PlanId: null, + MaxIpLimit: 2, + SubscriptionToken: "sub-token", + BillingEnabled: billingEnabled, + BillingPaidUntil: billingPaidUntil, + BillingSuspended: false + ); + + private ChangePlanCommandHandler CreateHandler(IAppDbContext dbContext, Guid userId) => + new( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(userId), + NullLogger.Instance + ); + + [Fact] + public async Task Handle_WhenPlanNotFound_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(Profile(userId)); + + var result = await CreateHandler(dbContext, userId) + .Handle(new ChangePlanCommand(Guid.NewGuid(), null, []), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(PlanErrors.NotFound, result.Error); + } + + [Fact] + public async Task Handle_WhenPlanDisabled_ReturnsDisabled() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var plan = Plan.Create("Скрытый", 6, 0); + plan.Update(plan.Name, plan.ConfigCount, plan.SortOrder, isEnabled: false); + dbContext.Plans.Add(plan); + await dbContext.SaveChangesAsync(CancellationToken.None); + _identityService.GetProfileAsync(userId, Arg.Any()).Returns(Profile(userId)); + + var result = await CreateHandler(dbContext, userId) + .Handle(new ChangePlanCommand(plan.Id, null, []), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(PlanErrors.Disabled, result.Error); + } + + [Fact] + public async Task Handle_WithCustomConfigCount_UpdatesQuotaImmediatelyWithoutBilling() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, configQuota: 3, billingEnabled: false)); + _identityService + .SetConfigQuotaAsync(userId, 8, null, Arg.Any()) + .Returns(Result.Success()); + + var result = await CreateHandler(dbContext, userId) + .Handle(new ChangePlanCommand(null, 8, []), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(8, result.Value.ConfigQuota); + Assert.Null(result.Value.PlanId); + Assert.Null(result.Value.TopUpAmount); + await _identityService.Received(1).SetConfigQuotaAsync(userId, 8, null, Arg.Any()); + Assert.Empty(dbContext.PaymentRequests); + } + + [Fact] + public async Task Handle_WhenUpgradingWithBillingAndActivePaidPeriod_CreatesTopUp() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var plan = Plan.Create("Плюс", 10, 0); + dbContext.Plans.Add(plan); + + var pricing = PricingSettings.CreateDefault(); + pricing.Update(200, 180, 160); + dbContext.PricingSettings.Add(pricing); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var paidUntil = DateTimeOffset.UtcNow.AddDays(30); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, configQuota: 3, billingEnabled: true, billingPaidUntil: paidUntil)); + _identityService + .SetConfigQuotaAsync(userId, 10, plan.Id, Arg.Any()) + .Returns(Result.Success()); + + var result = await CreateHandler(dbContext, userId) + .Handle(new ChangePlanCommand(plan.Id, null, []), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(10, result.Value.ConfigQuota); + Assert.Equal(plan.Id, result.Value.PlanId); + Assert.NotNull(result.Value.TopUpAmount); + var topUp = Assert.Single(dbContext.PaymentRequests); + Assert.Equal(PaymentRequestKind.PlanChangeTopUp, topUp.Kind); + Assert.Equal(result.Value.TopUpAmount, topUp.AmountSnapshot); + } + + [Fact] + public async Task Handle_WhenDowngradeWithoutEnoughConfigIdsToRevoke_ReturnsMustSelectConfigsToRevoke() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange( + VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "a"), + VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "b"), + VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "c") + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, configQuota: 3)); + + var result = await CreateHandler(dbContext, userId) + .Handle(new ChangePlanCommand(null, 1, []), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(PlanErrors.MustSelectConfigsToRevoke(2), result.Error); + } + + [Fact] + public async Task Handle_WhenDowngradeWithOnlyExpiredConfigs_StillRequiresSelectingThem() + { + // Все конфиги приостановлены за неуплату (Expired) — ни одного Active. Если считать + // excess только по Active, форма подумает, что отзывать нечего, а BillingConfigResumer + // потом молча вернёт все три в Active при следующей оплате — мимо новой квоты. + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); + dbContext.Inbounds.Add(inbound); + var configs = new[] + { + VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "a"), + VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "b"), + VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "c"), + }; + foreach (var c in configs) + c.Suspend(); + dbContext.VpnConfigs.AddRange(configs); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, configQuota: 3, billingEnabled: true)); + + var result = await CreateHandler(dbContext, userId) + .Handle(new ChangePlanCommand(null, 1, []), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(PlanErrors.MustSelectConfigsToRevoke(2), result.Error); + } + + [Fact] + public async Task Handle_WhenDowngradeSelectingExpiredConfig_RevokesIt() + { + using var dbContext = InMemoryDbContextFactory.Create(); + 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 toKeep = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "keep"); + var toRevoke = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "revoke"); + toRevoke.Suspend(); + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(toKeep, toRevoke); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, configQuota: 3, billingEnabled: true)); + _identityService + .SetConfigQuotaAsync(userId, 1, null, Arg.Any()) + .Returns(Result.Success()); + _gateway + .RemoveClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + toRevoke.ClientExternalId, + toRevoke.Protocol, + Arg.Any() + ) + .Returns(Result.Success()); + + var result = await CreateHandler(dbContext, userId) + .Handle(new ChangePlanCommand(null, 1, [toRevoke.Id]), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Revoked, toRevoke.Status); + Assert.Equal(ConfigStatus.Active, toKeep.Status); + } + + [Fact] + public async Task Handle_WhenDowngradeWithCorrectConfigIds_RevokesThemAndUpdatesQuota() + { + using var dbContext = InMemoryDbContextFactory.Create(); + 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 toKeep = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "keep"); + var toRevoke = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "revoke"); + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.AddRange(toKeep, toRevoke); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, configQuota: 3)); + _identityService + .SetConfigQuotaAsync(userId, 1, null, Arg.Any()) + .Returns(Result.Success()); + _gateway + .RemoveClientAsync( + Arg.Any(), + inbound.RemoteInboundId, + toRevoke.ClientExternalId, + toRevoke.Protocol, + Arg.Any() + ) + .Returns(Result.Success()); + + var result = await CreateHandler(dbContext, userId) + .Handle(new ChangePlanCommand(null, 1, [toRevoke.Id]), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Revoked, toRevoke.Status); + Assert.Equal(ConfigStatus.Active, toKeep.Status); + await _identityService.Received(1).SetConfigQuotaAsync(userId, 1, null, Arg.Any()); + await _notifier + .Received(1) + .NotifyConfigStatusChangedAsync( + userId, + toRevoke.Id, + ConfigStatus.Revoked, + Arg.Any() + ); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs index a2133b6..25e01f9 100644 --- a/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs @@ -23,7 +23,8 @@ public class AddTicketCommentCommandHandlerTests role, IsActivated: true, IsBlocked: false, - MaxConfigs: 3, + ConfigQuota: 3, + PlanId: null, MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "token", BillingEnabled: false, diff --git a/backend/tests/PnvPanel.Application.Tests/Support/CreateExtensionRequestTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/CreateExtensionRequestTicketCommandHandlerTests.cs index 0a291d5..af251a2 100644 --- a/backend/tests/PnvPanel.Application.Tests/Support/CreateExtensionRequestTicketCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Support/CreateExtensionRequestTicketCommandHandlerTests.cs @@ -23,7 +23,8 @@ public class CreateExtensionRequestTicketCommandHandlerTests "premium", IsActivated: true, IsBlocked: false, - MaxConfigs: 5, + ConfigQuota: 5, + PlanId: null, MaxIpLimit: 3, SubscriptionToken: "sub-token", BillingEnabled: billingEnabled, diff --git a/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs deleted file mode 100644 index 3e8c977..0000000 --- a/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using NSubstitute; -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Support; -using PnvPanel.Application.Support.CreateRoleRequest; -using PnvPanel.Application.Tests.TestSupport; -using PnvPanel.Domain.Support; -using Xunit; - -namespace PnvPanel.Application.Tests.Support; - -public class CreateRoleRequestTicketCommandHandlerTests -{ - private readonly IRoleService _roleService = Substitute.For(); - private readonly IRealtimeNotifier _notifier = Substitute.For(); - private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); - - [Fact] - public async Task Handle_ForExistingRole_CreatesTicket() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var userId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - var currentUser = FakeCurrentUser.Authenticated(userId, "alice"); - _roleService - .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "premium", 5, 2, false, false) }); - - var handler = new CreateRoleRequestTicketCommandHandler( - dbContext, - _roleService, - _notifier, - _telegramNotifier, - currentUser - ); - - var result = await handler.Handle( - new CreateRoleRequestTicketCommand(roleId, null, null, null, "нужно больше конфигов"), - CancellationToken.None - ); - await dbContext.SaveChangesAsync(CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Equal(roleId, result.Value.RequestedRoleId); - Assert.Equal("premium", result.Value.RequestedRoleName); - await _telegramNotifier - .Received(1) - .NotifyAdminsRoleRequestCreatedAsync( - Arg.Any(), - "alice", - "premium", - "нужно больше конфигов", - Arg.Any() - ); - } - - [Fact] - public async Task Handle_ForAdminRole_ReturnsForbidden() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var roleId = Guid.NewGuid(); - var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid()); - _roleService - .ListRolesAsync(Arg.Any()) - .Returns(new List { new(roleId, "admin", -1, -1, true, false) }); - - var handler = new CreateRoleRequestTicketCommandHandler( - dbContext, - _roleService, - _notifier, - _telegramNotifier, - currentUser - ); - - var result = await handler.Handle( - new CreateRoleRequestTicketCommand(roleId, null, null, null, "хочу быть админом"), - CancellationToken.None - ); - - Assert.False(result.IsSuccess); - Assert.Equal(SupportErrors.CannotRequestAdminRole, result.Error); - } - - [Fact] - public async Task Handle_ForNewRole_SetsProposedFields() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "bob"); - - var handler = new CreateRoleRequestTicketCommandHandler( - dbContext, - _roleService, - _notifier, - _telegramNotifier, - currentUser - ); - - var result = await handler.Handle( - new CreateRoleRequestTicketCommand(null, "custom", 10, 4, "нужна кастомная роль"), - CancellationToken.None - ); - - Assert.True(result.IsSuccess); - Assert.Equal("custom", result.Value.ProposedRoleName); - Assert.Equal(10, result.Value.ProposedMaxConfigs); - Assert.Equal(4, result.Value.ProposedMaxIpLimit); - } - - [Fact] - public async Task Handle_WhenPendingRoleRequestExists_ReturnsConflict() - { - using var dbContext = InMemoryDbContextFactory.Create(); - var userId = Guid.NewGuid(); - dbContext.SupportTickets.Add(SupportTicket.CreateRoleRequestForNewRole(userId, "x", 1, 1)); - await dbContext.SaveChangesAsync(CancellationToken.None); - - var currentUser = FakeCurrentUser.Authenticated(userId); - var handler = new CreateRoleRequestTicketCommandHandler( - dbContext, - _roleService, - _notifier, - _telegramNotifier, - currentUser - ); - - var result = await handler.Handle( - new CreateRoleRequestTicketCommand(null, "y", 2, 2, "ещё заявка"), - CancellationToken.None - ); - - Assert.False(result.IsSuccess); - Assert.Equal(SupportErrors.RoleRequestAlreadyPending, result.Error); - } -} diff --git a/backend/tests/PnvPanel.Application.Tests/Support/ListSelectableRoles/ListSelectableRolesQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/ListSelectableRoles/ListSelectableRolesQueryHandlerTests.cs deleted file mode 100644 index 9b94ee3..0000000 --- a/backend/tests/PnvPanel.Application.Tests/Support/ListSelectableRoles/ListSelectableRolesQueryHandlerTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using NSubstitute; -using PnvPanel.Application.Common.Interfaces; -using PnvPanel.Application.Support.ListSelectableRoles; -using PnvPanel.Application.Tests.TestSupport; -using Xunit; - -namespace PnvPanel.Application.Tests.Support.ListSelectableRoles; - -public class ListSelectableRolesQueryHandlerTests -{ - private readonly IRoleService _roleService = Substitute.For(); - private readonly IIdentityService _identityService = Substitute.For(); - - private static CurrentUserProfile Profile(Guid userId, Guid roleId, string role) => - new( - userId, - "user", - roleId, - role, - IsActivated: true, - IsBlocked: false, - MaxConfigs: 3, - MaxIpLimit: 1, - SubscriptionToken: "token", - BillingEnabled: false, - BillingPaidUntil: null, - BillingSuspended: false - ); - - [Fact] - public async Task Handle_ExcludesAdminAndCurrentUserRole() - { - var userId = Guid.NewGuid(); - var currentRoleId = Guid.NewGuid(); - var extendedRoleId = Guid.NewGuid(); - var adminRoleId = Guid.NewGuid(); - - _roleService - .ListRolesAsync(Arg.Any()) - .Returns( - new List - { - 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 - .GetProfileAsync(userId, Arg.Any()) - .Returns(Profile(userId, currentRoleId, "user")); - - var currentUser = FakeCurrentUser.Authenticated(userId, "alice"); - var handler = new ListSelectableRolesQueryHandler( - _roleService, - _identityService, - currentUser - ); - - var result = await handler.Handle(new ListSelectableRolesQuery(), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Equal(["extended"], result.Value.Select(r => r.Name)); - } - - [Fact] - public async Task Handle_WhenProfileMissing_StillExcludesAdmin() - { - var userId = Guid.NewGuid(); - var userRoleId = Guid.NewGuid(); - var adminRoleId = Guid.NewGuid(); - - _roleService - .ListRolesAsync(Arg.Any()) - .Returns( - new List - { - new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false), - new(userRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false), - } - ); - _identityService - .GetProfileAsync(userId, Arg.Any()) - .Returns((CurrentUserProfile?)null); - - var currentUser = FakeCurrentUser.Authenticated(userId, "alice"); - var handler = new ListSelectableRolesQueryHandler( - _roleService, - _identityService, - currentUser - ); - - var result = await handler.Handle(new ListSelectableRolesQuery(), CancellationToken.None); - - Assert.True(result.IsSuccess); - Assert.Equal(["user"], result.Value.Select(r => r.Name)); - } -} diff --git a/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs index 2039896..3385f71 100644 --- a/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs @@ -85,13 +85,13 @@ public class ReopenTicketCommandHandlerTests } [Fact] - public async Task Handle_WhenRoleRequestResolved_ReturnsOnlyBugReportCanBeReopened() + public async Task Handle_WhenExtensionRequestResolved_ReturnsOnlyBugReportCanBeReopened() { - // approve/reject — единственный способ решить заявку на роль/продление (см. CLAUDE.md); - // Reopen для них запрещён даже если тикет каким-то образом оказался Resolved. + // approve/reject — единственный способ решить заявку на продление (см. CLAUDE.md); + // Reopen для неё запрещён даже если тикет каким-то образом оказался Resolved. using var dbContext = InMemoryDbContextFactory.Create(); var userId = Guid.NewGuid(); - var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, Guid.NewGuid()); + var ticket = SupportTicket.CreateExtensionRequest(userId, 14); ticket.Resolve(); dbContext.SupportTickets.Add(ticket); await dbContext.SaveChangesAsync(CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs index 67a1b91..864bc5c 100644 --- a/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Telegram/GetLoginRequestStatusQueryHandlerTests.cs @@ -97,6 +97,7 @@ public class GetLoginRequestStatusQueryHandlerTests true, false, 3, + null, RoleQuota.Unlimited, "sub-token", false, @@ -169,6 +170,7 @@ public class GetLoginRequestStatusQueryHandlerTests true, true, 3, + null, RoleQuota.Unlimited, "sub-token", false, diff --git a/backend/tests/PnvPanel.Domain.Tests/Billing/RoleChangeTopUpTests.cs b/backend/tests/PnvPanel.Domain.Tests/Billing/PlanChangeTopUpTests.cs similarity index 63% rename from backend/tests/PnvPanel.Domain.Tests/Billing/RoleChangeTopUpTests.cs rename to backend/tests/PnvPanel.Domain.Tests/Billing/PlanChangeTopUpTests.cs index 490bb7c..cb78e3b 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Billing/RoleChangeTopUpTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Billing/PlanChangeTopUpTests.cs @@ -4,17 +4,17 @@ using Xunit; namespace PnvPanel.Domain.Tests.Billing; -public class RoleChangeTopUpTests +public class PlanChangeTopUpTests { private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); [Fact] - public void Compute_WhenNewRoleCheaper_ReturnsNull() + public void Compute_WhenNewPlanCheaper_ReturnsNull() { - var amount = RoleChangeTopUp.Compute( + var amount = PlanChangeTopUp.Compute( pricePerConfigPerMonth: 200, - oldMaxConfigs: 10, - newMaxConfigs: 3, + oldConfigCount: 10, + newConfigCount: 3, discountTiers: [], paidUntil: Now.AddDays(30), now: Now @@ -26,10 +26,10 @@ public class RoleChangeTopUpTests [Fact] public void Compute_WhenPaidUntilAlreadyExpired_ReturnsNull() { - var amount = RoleChangeTopUp.Compute( + var amount = PlanChangeTopUp.Compute( pricePerConfigPerMonth: 200, - oldMaxConfigs: 3, - newMaxConfigs: 10, + oldConfigCount: 3, + newConfigCount: 10, discountTiers: [], paidUntil: Now.AddDays(-1), now: Now @@ -39,12 +39,12 @@ public class RoleChangeTopUpTests } [Fact] - public void Compute_WhenEitherRoleUnlimited_ReturnsNull() + public void Compute_WhenEitherCountUnlimited_ReturnsNull() { - var amount = RoleChangeTopUp.Compute( + var amount = PlanChangeTopUp.Compute( pricePerConfigPerMonth: 200, - oldMaxConfigs: 3, - newMaxConfigs: -1, + oldConfigCount: 3, + newConfigCount: -1, discountTiers: [], paidUntil: Now.AddDays(30), now: Now @@ -56,12 +56,12 @@ public class RoleChangeTopUpTests [Fact] public void Compute_WhenUpgrading_ReturnsProratedDifference() { - // Старая роль: 200 * 3 = 600 ₽/мес. Новая: 200 * 10 = 2000 ₽/мес. Разница 1400 ₽/мес, + // Старый тариф: 200 * 3 = 600 ₽/мес. Новый: 200 * 10 = 2000 ₽/мес. Разница 1400 ₽/мес, // за 30 дней (ровно месяц) — 1400 ₽. - var amount = RoleChangeTopUp.Compute( + var amount = PlanChangeTopUp.Compute( pricePerConfigPerMonth: 200, - oldMaxConfigs: 3, - newMaxConfigs: 10, + oldConfigCount: 3, + newConfigCount: 10, discountTiers: [], paidUntil: Now.AddDays(30), now: Now @@ -74,10 +74,10 @@ public class RoleChangeTopUpTests public void Compute_ProratesToRemainingDaysOnly() { // Та же разница 1400 ₽/мес, но остаётся только 15 из 30 дней — половина. - var amount = RoleChangeTopUp.Compute( + var amount = PlanChangeTopUp.Compute( pricePerConfigPerMonth: 200, - oldMaxConfigs: 3, - newMaxConfigs: 10, + oldConfigCount: 3, + newConfigCount: 10, discountTiers: [], paidUntil: Now.AddDays(15), now: Now @@ -87,16 +87,16 @@ public class RoleChangeTopUpTests } [Fact] - public void Compute_AppliesDiscountTiersToBothRoles() + public void Compute_AppliesDiscountTiersToBothCounts() { var tiers = new[] { PricingDiscountTier.Create(Guid.NewGuid(), minConfigs: 10, discountPercent: 10) }; - // Старая роль (3 конфига, без скидки): 200*3 = 600. Новая (10 конфигов, порог скидки достигнут): + // Старый тариф (3 конфига, без скидки): 200*3 = 600. Новый (10 конфигов, порог скидки достигнут): // 200*10 = 2000, -10% = 1800. Разница 1200 ₽/мес, за 30 дней — 1200 ₽. - var amount = RoleChangeTopUp.Compute( + var amount = PlanChangeTopUp.Compute( pricePerConfigPerMonth: 200, - oldMaxConfigs: 3, - newMaxConfigs: 10, + oldConfigCount: 3, + newConfigCount: 10, discountTiers: tiers, paidUntil: Now.AddDays(30), now: Now diff --git a/backend/tests/PnvPanel.Domain.Tests/Plans/PlanTests.cs b/backend/tests/PnvPanel.Domain.Tests/Plans/PlanTests.cs new file mode 100644 index 0000000..8e62fae --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Plans/PlanTests.cs @@ -0,0 +1,31 @@ +using PnvPanel.Domain.Plans; +using Xunit; + +namespace PnvPanel.Domain.Tests.Plans; + +public class PlanTests +{ + [Fact] + public void Create_SetsFieldsAndEnablesByDefault() + { + var plan = Plan.Create("Стандарт", 3, 0); + + Assert.Equal("Стандарт", plan.Name); + Assert.Equal(3, plan.ConfigCount); + Assert.Equal(0, plan.SortOrder); + Assert.True(plan.IsEnabled); + } + + [Fact] + public void Update_ChangesFieldsIncludingIsEnabled() + { + var plan = Plan.Create("Стандарт", 3, 0); + + plan.Update("Плюс", 6, 1, false); + + Assert.Equal("Плюс", plan.Name); + Assert.Equal(6, plan.ConfigCount); + Assert.Equal(1, plan.SortOrder); + Assert.False(plan.IsEnabled); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs b/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs index 1b0b2f8..2b41958 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs @@ -16,33 +16,7 @@ public class SupportTicketTests Assert.Equal(userId, ticket.UserId); Assert.Equal(TicketType.BugReport, ticket.Type); Assert.Equal(TicketStatus.Open, ticket.Status); - Assert.Null(ticket.RequestedRoleId); - Assert.Null(ticket.ProposedRoleName); - } - - [Fact] - public void CreateRoleRequestForExistingRole_SetsRequestedRoleId() - { - var userId = Guid.NewGuid(); - var roleId = Guid.NewGuid(); - - var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId); - - Assert.Equal(TicketType.RoleRequest, ticket.Type); - Assert.Equal(roleId, ticket.RequestedRoleId); - Assert.Null(ticket.ProposedRoleName); - } - - [Fact] - public void CreateRoleRequestForNewRole_SetsProposedFields() - { - var ticket = SupportTicket.CreateRoleRequestForNewRole(Guid.NewGuid(), "premium", 5, 3); - - Assert.Equal(TicketType.RoleRequest, ticket.Type); - Assert.Null(ticket.RequestedRoleId); - Assert.Equal("premium", ticket.ProposedRoleName); - Assert.Equal(5, ticket.ProposedMaxConfigs); - Assert.Equal(3, ticket.ProposedMaxIpLimit); + Assert.Null(ticket.RequestedDays); } [Fact] @@ -56,8 +30,6 @@ public class SupportTicketTests Assert.Equal(TicketType.ExtensionRequest, ticket.Type); Assert.Equal(TicketStatus.Open, ticket.Status); Assert.Equal(14, ticket.RequestedDays); - Assert.Null(ticket.RequestedRoleId); - Assert.Null(ticket.ProposedRoleName); } [Fact] diff --git a/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs b/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs index 3b75611..162bb59 100644 --- a/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs +++ b/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs @@ -11,7 +11,7 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory) private const int Quota = 2; private const int ConcurrentAttempts = 6; - private sealed record RoleResponse(Guid Id, string Name, int MaxConfigs, bool IsSystem); + private sealed record RoleResponse(Guid Id, string Name, bool IsSystem); private sealed record NodeResponse(Guid Id, string Name); @@ -33,7 +33,7 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory) DateTimeOffset CreatedAt ); - private sealed record MyConfigsResponse(List Configs, int MaxConfigs); + private sealed record MyConfigsResponse(List Configs, int ConfigQuota); /// /// Доказывает, что pg_advisory_xact_lock в CreateVpnConfigCommandHandler реально защищает @@ -54,7 +54,7 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory) var updateRoleResponse = await adminClient.SendPutJsonAsync( $"/api/admin/roles/{userRole.Id}", - new { maxConfigs = Quota, maxIpLimit = -1 } + new { maxIpLimit = -1, billingEnabled = false } ); Assert.Equal(HttpStatusCode.OK, updateRoleResponse.StatusCode); @@ -109,6 +109,13 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory) ); Assert.Equal(HttpStatusCode.NoContent, approveResponse.StatusCode); + // Квота конфигов больше не на роли — пользователь сам выбирает тариф (см. ChangePlanCommand). + var changePlanResponse = await userClient.PostJsonAsync( + "/api/plans/change", + new { customConfigCount = Quota, configIdsToRevoke = Array.Empty() } + ); + Assert.Equal(HttpStatusCode.OK, changePlanResponse.StatusCode); + var tasks = Enumerable .Range(0, ConcurrentAttempts) .Select(async i => diff --git a/docs/api-design.md b/docs/api-design.md index c8d7f84..b572dae 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -64,7 +64,7 @@ rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 за | Метод | Путь | Тело запроса | Тело ответа | | ------ | --------------------------------- | ------------------------------------ | --------------------------------------- | | GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` | -| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }` — **без пагинации**, весь список сразу | +| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], configQuota, planId }` — **без пагинации**, весь список сразу | | POST | `/api/configs` | `{ inboundId, label? }` | `VpnConfigDto` (`200 OK`, не 201) | | PATCH | `/api/configs/{id}` | `{ label? }` | `VpnConfigDto` | | POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) | @@ -80,7 +80,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро `connectionString`. Все `/api/configs/*`, `/api/news`, `/api/apps` без активации → `403` (`Auth.NotActivated`, единая -проверка `RequireActivationBehavior`); создание сверх квоты роли → `409` (`Configs.QuotaExceeded`). +проверка `RequireActivationBehavior`); создание сверх квоты тарифа (`AppUser.ConfigQuota`) → `409` +(`Configs.QuotaExceeded`). ## Apps — каталог приложений @@ -154,8 +155,8 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | Метод | Путь | Тело запроса | Тело ответа | | ----- | -------------------------------------------- | ---------------------- | ------------- | -| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }`. `PaymentRequestDto` теперь несёт `kind` (`Subscription`/`RoleChangeTopUp`) и `period: PaymentPeriod \| null` (`null` для `RoleChangeTopUp` — см. domain-model.md#rolechangetopup). Если у пользователя одновременно активны заявки обоих `Kind`, видна только одна (см. известное ограничение там же) | -| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`Kind.Subscription`; `409 Billing.ActiveRequestExists`, если уже есть активная заявка **того же Kind** — активный `RoleChangeTopUp` не блокирует; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) | +| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }`. `PaymentRequestDto` теперь несёт `kind` (`Subscription`/`PlanChangeTopUp`) и `period: PaymentPeriod \| null` (`null` для `PlanChangeTopUp` — см. domain-model.md#planchangetopup). Если у пользователя одновременно активны заявки обоих `Kind`, видна только одна (см. известное ограничение там же) | +| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`Kind.Subscription`; `409 Billing.ActiveRequestExists`, если уже есть активная заявка **того же Kind** — активный `PlanChangeTopUp` не блокирует; `409 Billing.UnlimitedRoleNotSupported` при `ConfigQuota=-1` (безлимит — только у `admin`); `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 не привязан) | @@ -168,28 +169,24 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | Метод | Путь | Тело запроса | Тело ответа | | ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- | -| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке | -| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена (включая скидочную лесенку `discountTiers`), что и `/api/admin/pricing`, для справки в диалоге заявки на роль | +| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена (включая скидочную лесенку `discountTiers`), что и `/api/admin/pricing`, для справки на странице смены тарифа (`/plan`, см. Plans ниже) | | GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList` (только свои) | | GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) | | POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` | -| POST | `/api/support/tickets/role-requests` | `{ existingRoleId? \| (newRoleName, newRoleMaxConfigs, newRoleMaxIpLimit), justification }` | `TicketDetailDto` | | POST | `/api/support/tickets/extension-requests` | `{ requestedDays, justification }` | `TicketDetailDto` (`403 Billing.NotEnabled`, если роль не billing; `409 Support.ExtensionRequestAlreadyPending`) | | POST | `/api/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` | | POST | `/api/support/tickets/{id}/reopen` | — | `204 No Content` (только владелец, только из `Resolved`) | | GET | `/api/support/attachments/{id}` | — | бинарный поток с `Content-Type` вложения | `TicketSummaryDto`: `{ id, userId, userName, type, status, createdAt, lastActivityAt }` — один DTO для -своего и админского списков. `TicketDetailDto` добавляет `requestedRoleId, requestedRoleName, -proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, requestedDays, comments: TicketCommentDto[]`. -`TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`. +своего и админского списков. `TicketDetailDto` добавляет `requestedDays, comments: +TicketCommentDto[]`. `TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: +TicketAttachmentDto[] }`. -Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403 -Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом -запросе на роль → `409 Support.RoleRequestAlreadyPending`; аналогично для продления → -`409 Support.ExtensionRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете → -`409 Support.TicketClosed`. Вложения отдаются не статикой — `` не может передать -`Authorization`-заголовок, фронт качает их как `Blob` через `fetch` и рендерит `Object URL`. +Заявка при существующем открытом запросе на продление → `409 Support.ExtensionRequestAlreadyPending`. +`POST …/comments` на `Closed`-тикете → `409 Support.TicketClosed`. Вложения отдаются не статикой — +`` не может передать `Authorization`-заголовок, фронт качает их как `Blob` через `fetch` +и рендерит `Object URL`. ## Admin — Support @@ -201,25 +198,19 @@ Support.CannotRequestAdminRole`), либо все три поля новой р | GET | `/api/admin/support/tickets` | query: `type?, status?, page, pageSize` | `PagedList` (все пользователи) | | GET | `/api/admin/support/tickets/{id}` | — | `TicketDetailDto` | | POST | `/api/admin/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` | -| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (любой тип, только из `Open`) | -| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) | -| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль сразу; если новая роль дороже старой и у пользователя активен `BillingPaidUntil` — дополнительно создаёт `PaymentRequest(Kind.RoleChangeTopUp)` на разницу в цене, см. domain-model.md#rolechangetopup) | -| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) | +| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (только `BugReport`, только из `Open`) | +| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (только `BugReport`, финал) | | POST | `/api/admin/support/tickets/{id}/approve-extension` | — | `204 No Content` (только `ExtensionRequest`/`Open`; продлевает `BillingPaidUntil` на `RequestedDays`) | | POST | `/api/admin/support/tickets/{id}/reject-extension` | `{ reason? }` | `204 No Content` (только `ExtensionRequest`; `reason` уходит комментарием) | -Обработать **собственный** тикет админу можно (в т.ч. одобрить свою же заявку на роль) — resolve/close/ -reject/approve владением тикета не ограничены. Единственное реальное ограничение — `approve` вернёт -`409 Roles.CannotRemoveLastAdmin` через `ChangeUserRoleAsync`, если заявка (своя или чужая) снимает -`admin` с последнего администратора в системе. +Обработать **собственный** тикет админу можно — resolve/close/approve-extension/reject-extension +владением тикета не ограничены (единственный админ иначе не смог бы закрыть свой же тикет). -`approve`/`reject` — единственный способ решить заявку на роль (нельзя одобрить через `resolve`). -При одобрении: если заявка на существующую роль — сразу `ChangeUserRoleCommand`-эквивалент; если на -новую — сперва создаётся `AppRole` (`IRoleService.CreateRoleAsync`), затем назначается. То же самое -администратор может сделать **из Telegram, не заходя на сайт** — инлайн-кнопки на уведомлении о -заявке (см. [telegram-bot.md](telegram-bot.md)); для баг-репортов в Telegram только кнопка-ссылка -на `/admin/support?ticket={id}` — переписка и вложения только на сайте (отдельного роута на конкретный -тикет нет, `?ticket=` открывает диалог поверх списка). +`approve-extension`/`reject-extension` — единственный способ решить заявку на продление (нельзя +одобрить через `resolve`). То же самое администратор может сделать **из Telegram, не заходя на +сайт** — инлайн-кнопки на уведомлении о заявке (см. [telegram-bot.md](telegram-bot.md)); для +баг-репортов в Telegram только кнопка-ссылка на `/admin/support?ticket={id}` — переписка и вложения +только на сайте (отдельного роута на конкретный тикет нет, `?ticket=` открывает диалог поверх списка). ## Admin — Maintenance @@ -262,16 +253,56 @@ 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, billingEnabled }` | `RoleDto` (`400`, если `billingEnabled=true` для `name="admin"`) | -| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (то же ограничение на `admin`; включение `billingEnabled` ретроактивно выдаёт грейс-период уже назначенным пользователям без `PaidUntil`) | +| POST | `/api/admin/roles` | admin | `{ name, maxIpLimit, billingEnabled }` | `RoleDto` (`400`, если `billingEnabled=true` для `name="admin"`) | +| PUT | `/api/admin/roles/{id}` | admin | `{ 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` (глобальная справочная цена за конфиг **в месяц** + скидочная лесенка `discountTiers: { minConfigs, discountPercent }[]`, одна на весь сервис — не per-роль) | +| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ; см. domain-model.md#approle — переход на/с `admin` автоматически выдаёт/сбрасывает безлимитную квоту) | +| PATCH | `/api/admin/users/{id}/plan` | admin | `{ planId? \| customConfigCount? }` | `204 No Content` — прямой оверрайд квоты конфигов пользователя (ровно одно из полей); в отличие от `POST /api/plans/change` — без пикера конфигов на понижение (грандфазеринг) и без доплаты; `customConfigCount = -1` (безлимит) — `409 Plans.UnlimitedOnlyForAdmin`, если текущая роль цели не `admin` | +| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц** + скидочная лесенка `discountTiers: { minConfigs, discountPercent }[]`, одна на весь сервис — не per-роль/тариф) | | PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear?, discountTiers: { minConfigs, discountPercent }[] }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого, либо `discountTiers` не уникальны/не прогрессивны — см. domain-model.md#pricingdiscounttier). `discountTiers` при сохранении полностью заменяет прежний набор | Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через approve/reject над `ActivationRequest`. +## Plans (пользователь) — самостоятельная смена тарифа + +Группа `/api/plans`, `RequireAuthorization()` + `IRequiresActivation`. Полная модель — см. +[domain-model.md](domain-model.md#plan--тариф-самообслуживание-квота-конфигов). + +| Метод | Путь | Тело запроса | Тело ответа | +| ----- | -------------------- | -------------------------------------------------------------------- | ------------- | +| GET | `/api/plans` | — | `PlanDto[]` (`{ id, name, configCount }`, только `isEnabled == true`, сортировка `sortOrder asc`) | +| GET | `/api/plans/status` | — | `MyPlanStatusDto { configQuota, planId, activeConfigCount, billingEnabled, billingPaidUntil }` | +| POST | `/api/plans/change` | `{ planId? \| customConfigCount?, configIdsToRevoke: Guid[] }` | `ChangePlanResultDto { configQuota, planId, topUpAmount: int? }` | + +`POST /api/plans/change` — ровно одно из `planId`/`customConfigCount` (иначе `400`); квота меняется +**сразу**, без подтверждения админом. Если новое количество меньше текущего числа конфигов, всё ещё +занимающих квоту (`Active` **и** `Expired` — приостановленные за неуплату тоже считаются, иначе их +можно было бы молча вернуть сверх новой квоты следующей оплатой) — `configIdsToRevoke` обязателен и +должен содержать ровно (это число − новое количество) id, иначе `400 Plans.MustSelectConfigsToRevoke` +(фронт по этой ошибке показывает пикер конфигов, включая приостановленные). Если у роли пользователя +включён биллинг, увеличение тарифа при активном +`BillingPaidUntil` создаёт `PaymentRequest(Kind.PlanChangeTopUp)` на разницу в цене — +`topUpAmount` в ответе ненулевой, см. [domain-model.md](domain-model.md#planchangetopup). +`customConfigCount` ограничен `[Plans__MinCustomConfigCount, Plans__MaxCustomConfigCount]` +(по умолчанию `[3, 50]`) — иначе `400`; `-1` (безлимит) недоступен через самообслуживание в +принципе (вне допустимого диапазона). Тариф выключен/не найден → `404 Plans.NotFound` / +`400 Plans.Disabled`. + +## Admin — Plans + +Группа `/api/admin/plans`, `RequireAuthorization(RoleNames.Admin)`. Точное зеркало `/api/admin/apps`. + +| Метод | Путь | Тело запроса | Тело ответа | +| ------ | ------------------------- | ----------------------------------------------------- | ------------- | +| GET | `/api/admin/plans` | — | `AdminPlanDto[]` (вкл. выключенные, `{ id, name, configCount, sortOrder, isEnabled }`) | +| POST | `/api/admin/plans` | `{ name, configCount, sortOrder }` | `AdminPlanDto` | +| PUT | `/api/admin/plans/{id}` | `{ name, configCount, sortOrder, isEnabled }` | `AdminPlanDto` | +| DELETE | `/api/admin/plans/{id}` | — | `204 No Content` | + +`ConfigCount` — только положительное число (каталожные тарифы не поддерживают безлимит; безлимит +доступен исключительно роли `admin` через `AppUser.ConfigQuota=-1`, вне каталога). + ## Admin — Billing | Метод | Путь | Роль | Тело запроса | Тело ответа | @@ -279,7 +310,7 @@ approve/reject над `ActivationRequest`. | GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | | PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` | | GET | `/api/admin/billing/requests` | admin | query: `status?, kind?, search?, page=1, pageSize=20` (`search` — по имени пользователя, резолвится до пагинации) | `PagedList` (включает `userName`, `kind`, `period: PaymentPeriod \| null`) | -| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (для `Kind.Subscription` продлевает `BillingPaidUntil` и возвращает приостановленные конфиги в `Active`; для `Kind.RoleChangeTopUp` — только помечает `Confirmed`, `BillingPaidUntil` не трогает, см. domain-model.md#rolechangetopup) | +| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (для `Kind.Subscription` продлевает `BillingPaidUntil` и возвращает приостановленные конфиги в `Active`; для `Kind.PlanChangeTopUp` — только помечает `Confirmed`, `BillingPaidUntil` не трогает, см. domain-model.md#planchangetopup) | | POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` | | POST | `/api/admin/billing/gift` | admin | `{ userId, days }` | `204 No Content` (продлевает `BillingPaidUntil` на `days` от `max(текущий, сейчас)`, возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; `403 Billing.NotEnabled`, если роль пользователя не billing) | @@ -384,7 +415,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи | 401 | Нет/просрочен/невалиден access-токен | | 403 | Нет прав по роли, либо `Auth.NotActivated`, либо `Configs.BillingRequired` (просрочена оплата) | | 404 | Ресурс не найден | -| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed`, `Billing.ActiveRequestExists`, `Billing.UnlimitedRoleNotSupported`, `Telegram.NotLinked` | +| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.ExtensionRequestAlreadyPending`, `Support.TicketClosed`, `Billing.ActiveRequestExists`, `Billing.UnlimitedRoleNotSupported`, `Plans.MustSelectConfigsToRevoke`, `Plans.UnlimitedOnlyForAdmin`, `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 620442d..a09f08f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,7 +146,7 @@ POST /api/configs → CreateVpnConfigCommandHandler · проверяет роль инбаунда (доменная проверка) · SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания - · пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs + · пересчитывает текущее число активных конфигов и сверяет с AppUser.ConfigQuota · IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId · VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext · при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync) diff --git a/docs/domain-model.md b/docs/domain-model.md index 1e90028..846a236 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -4,10 +4,11 @@ `AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser`/ `IdentityRole`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`. -Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов — -только через `AppRole.MaxConfigs`. Есть глобальная справочная цена за один конфиг (`PricingSettings`; -редактирует только `admin`, но справочно видна и активированным пользователям в заявке на роль) — -используется и биллингом (см. ниже) для расчёта суммы заявки на оплату. +Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов — через +`AppUser.ConfigQuota` (самообслуживание, см. `Plan` ниже). Есть глобальная справочная цена за один +конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным +пользователям на странице смены тарифа) — используется и биллингом (см. ниже) для расчёта суммы +заявки на оплату. **Биллинг (подписка по сроку) реализован, но опционален и включается per-роль** (`AppRole.BillingEnabled`, недоступен для `admin`) — см. [Billing](#billing--подписка-по-сроку). @@ -16,8 +17,9 @@ ## Диаграмма связей ``` -AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken] - ├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs) +AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken, ConfigQuota, PlanId] + ├─*───1─ AppRole (ровно одна роль; роль несёт лимит устройств MaxIpLimit) + ├─0..1─ Plan (последний выбранный тариф; квота — на AppUser, не live-linked) ├─1───*─ VpnConfig │ └─1─ Inbound ─*─1─ Node │ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде) @@ -29,7 +31,7 @@ AuditLog (append-only журнал действий; сс ClientApp (каталог приложений-клиентов; группируется по OperatingSystem) NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям) AppUser - └─0..*─ SupportTicket (баг-репорт/предложение либо заявка на роль) + └─0..*─ SupportTicket (баг-репорт/предложение либо заявка на продление) └─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование) └─0..*─ TicketAttachment (изображения, диск-хранилище) ``` @@ -83,7 +85,7 @@ AppUser Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, и **роль пользователя входит в `AllowedRoles`**. Публикация инбаунда админом включает выбор `AllowedRoles` (напр. «Германия (Trojan)» → роли `user`, `vip`). Лимита числа клиентов на инбаунд нет — квота -ограничивается только на уровне пользователя (`AppRole.MaxConfigs`). +ограничивается только на уровне пользователя (`AppUser.ConfigQuota`). **Синхронизация и пропажа инбаунда с панели** (`SyncNodeCommandHandler`, кнопка «Синхронизировать»): инбаунд, не пришедший в очередном ответе 3x-ui, считается пропавшим. Если по нему нет ни одного @@ -141,13 +143,15 @@ AppUser `IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация). - **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания - (`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли. Тот - же паттерн обобщён в `Application/Common/Concurrency/AdvisoryLock.cs` (`AdvisoryLock.RunAsync`) и - используется во всех "проверил статус — потом изменил" хендлерах одобрения/отклонения заявок и - оплат (`Confirm`/`RejectPaymentRequestCommandHandler`, `Approve`/`RejectRoleRequestCommandHandler`, - `Approve`/`RejectExtensionRequestCommandHandler`, `Create`/`GetLoginRequestStatusQueryHandler`, - `Create...RequestTicket`/`CreatePaymentRequestCommandHandler`) — без него параллельное одобрение той - же заявки с сайта и из Telegram могло бы оба пройти проверку статуса и оба начислить дни/роль/оплату. + (`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить квоту. Тот же + ключ (`userId`) использует и `ChangePlanCommandHandler` — самостоятельная смена тарифа не может + гоняться с параллельным созданием конфига. Тот же паттерн обобщён в + `Application/Common/Concurrency/AdvisoryLock.cs` (`AdvisoryLock.RunAsync`) и используется во всех + "проверил статус — потом изменил" хендлерах одобрения/отклонения заявок и оплат + (`Confirm`/`RejectPaymentRequestCommandHandler`, `Approve`/`RejectExtensionRequestCommandHandler`, + `Create`/`GetLoginRequestStatusQueryHandler`, `Create...RequestTicket`/ + `CreatePaymentRequestCommandHandler`) — без него параллельное одобрение той же заявки с сайта и из + Telegram могло бы оба пройти проверку статуса и оба начислить дни/оплату. На нерелационном EF-провайдере (InMemory в `PnvPanel.Application.Tests`) лок автоматически пропускается — сериализующее поведение проверяется только в `PnvPanel.IntegrationTests` (реальный Postgres). @@ -165,7 +169,7 @@ AppUser - `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента). - Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его - per-конфиг. Как и `MaxConfigs`, лимит применяется только к **новым** клиентам: смена роли/квоты не + per-конфиг. Как и `ConfigQuota`, лимит применяется только к **новым** клиентам: смена роли/тарифа не трогает уже созданных клиентов в 3x-ui (см. `IXuiPanelGateway.UpdateClientAsync`, где `LimitIp` всегда `null` — «не менять»). - `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения. @@ -181,13 +185,14 @@ AppUser просмотр списка, редактирование, ротация, отзыв, получение ссылки/подписки на свои конфиги, а также чтение новостей и каталога приложений — единая проверка в `RequireActivationBehavior` (pipeline behavior, маркер `IRequiresActivation` на команде/запросе), а не разбросанные проверки в хендлерах. -- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`; - роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже. +- Число активных конфигов пользователя не может превышать **его квоту** (`AppUser.ConfigQuota`; + `-1` — без лимита, только для `admin`). Квота задаётся тарифом (`Plan`, самообслуживание, см. + ниже), не ролью. См. `Plan` ниже. - Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`). -- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли). +- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота пользователя). > Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него -> никогда не переводит конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см. +> никогда не переводит конфиг. Квота на число конфигов реализована через `AppUser.ConfigQuota` (см. > [tech-stack.md](tech-stack.md)). Истечение срока — только для billing-ролей, см. Billing ниже. ### TrafficSample — история трафика (для графиков) @@ -315,6 +320,8 @@ AppUser | `TelegramUserId` | `long?` | Id пользователя Telegram; **уникальный**; null до привязки | | `TelegramUsername` | `string?` | @username на момент привязки (для отображения) | | `TelegramLinkedAt` | `DateTimeOffset?` | Когда привязан | +| `ConfigQuota` | `int` | Фактическая квота активных конфигов (замена бывшего `AppRole.MaxConfigs`); `-1` = без лимита. Меняется через `ChangePlanCommand` (самообслуживание) или `AdminSetUserPlanCommand` | +| `PlanId` | `Guid?` | Какой каталожный `Plan` выбран последним; обычная колонка без FK (см. `Inbound.AllowedRoleIds`); `null`, если квота задана вручную (кастомное число) или прямым оверрайдом админа | Инварианты: один `TelegramUserId` ↔ один аккаунт (повторная привязка требует `/unlink`); неактивированный пользователь не имеет доступа к конфигам, новостям и каталогу приложений (см. выше); @@ -322,39 +329,41 @@ AppUser **Блокировка** (`IsBlocked = true`) переводит все конфиги в `Disabled` (отключение клиентов в 3x-ui); разблокировка включает их обратно. У пользователя ровно одна роль. +**`ConfigQuota = -1` (безлимит) зарезервирован за ролью `admin`** — самостоятельная смена тарифа +(`ChangePlanCommand`) не может выставить безлимит (валидатор требует конечное число в диапазоне +`Plans__MinCustomConfigCount`..`Plans__MaxCustomConfigCount`), и админский оверрайд +(`AdminSetUserPlanCommand`) тоже отказывает не-admin'у (`PlanErrors.UnlimitedOnlyForAdmin`). +`RoleService.ChangeUserRoleAsync` выставляет `ConfigQuota = -1` автоматически при назначении роли +`admin` и сбрасывает её на `Roles__DefaultUserMaxConfigs` при уходе с `admin` (если она была +безлимитной) — иначе бывший админ остался бы с безлимитом навсегда. + **Восстановление пароля**: только через привязанный Telegram (passwordless-вход → смена пароля в настройках, либо reset-флоу в боте). Если Telegram не привязан — пароль сбрасывает **админ** (`ResetUserPasswordCommand`). Пока Telegram не привязан, UI **настойчиво напоминает** привязать его (единственный self-service способ восстановления). -### AppRole — роль с квотой (Identity, динамическая) -Расширяет `IdentityRole`. Роли **создаёт админ** и назначает пользователям; роль несёт квоту -на число конфигов и лимит одновременных IP на клиента в 3x-ui. +### AppRole — роль (Identity, динамическая) +Расширяет `IdentityRole`. Роли **создаёт админ** и назначает пользователям; роль несёт лимит +одновременных IP на клиента в 3x-ui, доступ к инбаундам (`Inbound.AllowedRoles`) и флаг биллинга. +**Квота конфигов на роли больше не хранится** — она у пользователя (`AppUser.ConfigQuota`, см. выше), +управляется самостоятельной сменой тарифа (`Plan`, см. ниже), не ролью. | Поле | Тип | Заметки | | ------------ | -------- | --------------------------------------------------------------- | | `Id` | `Guid` | PK | | `Name` | `string` | Напр. `admin`, `user`, `vip` | -| `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). -**У пользователя ровно одна роль**; его квоты = `MaxConfigs`/`MaxIpLimit` этой роли (`admin` → без лимита). - -**Понижение роли (грандфазеринг)**: смену роли на роль с меньшей квотой разрешаем даже если текущих -конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**, -пока число активных не станет меньше квоты. Форс-отзыв лишних не делаем. +Сидируются: `admin` (без лимита IP) и `user` (`MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, +по умолчанию 2). **У пользователя ровно одна роль.** **Нельзя снять `admin` с последнего администратора**: `IRoleService.ChangeUserRoleAsync` перед сменой роли проверяет — если у пользователя сейчас `admin`, а новая роль другая, и админов в системе ровно -один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Единая точка защиты — работает -и при прямой смене роли из `/admin/users`, и при одобрении заявки на роль через `SupportTicket` -(`ApproveRoleRequestCommandHandler` вызывает тот же `ChangeUserRoleAsync`), в том числе когда админ -одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не -плодить тикеты, которые некому обработать, если админ единственный. +один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Тот же метод — единственная +точка смены роли (прямая смена из `/admin/users`, самостоятельной заявки на роль больше нет, см. +`SupportTicket` ниже). **Удаление роли** (`IRoleService.DeleteRoleAsync`): запрещено для системных ролей и пока есть живые пользователи с этой ролью (`RoleErrors.RoleInUse`). Живых пользователей нет — но `Inbound.AllowedRoleIds` @@ -362,6 +371,63 @@ UI **настойчиво напоминает** привязать его (ед хендлер подчищает такие ссылки (`Inbound.RemoveAllowedRole`), иначе "мёртвый" Id молча оставался бы висеть в массиве — не пуская никого нового, но и не давая понять почему. +### Plan — тариф (самообслуживание, квота конфигов) +Каталог квот конфигов, из которого пользователь выбирает себе тариф **сам, без подтверждения +админа** — заменяет прежнюю схему «квота = квота роли». Роль (выше) продолжает управлять только +лимитом устройств, доступом к инбаундам и флагом биллинга. + +| Поле | Тип | Заметки | +| ------------ | -------- | ----------------------------------------------------------------- | +| `Id` | `Guid` | PK | +| `Name` | `string` | Напр. «Стандарт», «Плюс», «Про» | +| `ConfigCount`| `int` | Количество конфигов; **≥ 1** — каталожные тарифы не поддерживают безлимит (тот доступен только `admin`, см. `AppUser.ConfigQuota`) | +| `SortOrder` | `int` | Порядок в списке (админка и страница `/plan`) | +| `IsEnabled` | `bool` | Показывать ли тариф пользователям для выбора | + +Сидируются 3 тарифа при первом старте (`IPlanSeeder`, если таблица пуста): «Стандарт» (3), +«Плюс» (6), «Про» (9) — то же соглашение, что у `PricingSettingsSeeder`. Полный CRUD только у +админа (`/api/admin/plans`); `GET /api/plans` (только `IsEnabled`, активированным) — для страницы +`/plan`. + +**Выбор тарифа не привязан жёстко к каталогу** — пользователь может вместо этого ввести +произвольное количество конфигов (`CustomConfigCount`), ограниченное настройками +`Plans__MinCustomConfigCount` (по умолчанию 3) и `Plans__MaxCustomConfigCount` (по умолчанию 50); +в этом случае `AppUser.PlanId` остаётся `null` — это не "тариф", просто число. Квота +(`AppUser.ConfigQuota`) — снапшот на момент выбора, не live-ссылка на `Plan`: последующее изменение +`Plan.ConfigCount` админом не трогает уже выбравших его пользователей (симметрично тому, как +`PaymentRequest.AmountSnapshot` не меняется при правке `PricingSettings` задним числом). Поэтому +удаление тарифа (`DeletePlanCommandHandler`) не проверяет "используется ли он ещё" — `PlanId` +пользователя может молча указывать на удалённую запись, как `Inbound.AllowedRoleIds`. + +**`ChangePlanCommand`** (`POST /api/plans/change`, `IRequiresActivation`) — самостоятельная смена, +без подтверждения админа: +- Ровно одно из `PlanId`/`CustomConfigCount` в запросе. +- Квота меняется **сразу**. Если новая квота **больше** текущей и у роли пользователя включён + биллинг с активным `BillingPaidUntil` — создаётся доплата (`PlanChangeTopUp`, см. Billing ниже) за + разницу в цене на оставшийся оплаченный срок — тот же принцип, что раньше был у смены роли. +- Если новая квота **меньше** текущего числа конфигов, всё ещё занимающих квоту (`Active` **и** + `Expired`), — самостоятельная смена требует явно указать, какие именно конфиги отозвать + (`ConfigIdsToRevoke`, ровно `(активные+приостановленные) − новая_квота` штук; иначе + `Plans.MustSelectConfigsToRevoke`). Это осознанное отличие от грандфазеринга при понижении: + пользователь меняет тариф сам, в реальном времени, поэтому можно и нужно спросить его сразу, а не + оставлять лишние конфиги висеть молча. `Expired` (приостановленные за неуплату) считаются наравне с + `Active` — иначе пользователь мог бы обойти пикер, понизив тариф именно во время приостановки (все + конфиги временно не `Active`), а затем оплатить: `BillingConfigResumer.ResumeConfigsAsync` + возвращает в `Active` **все** приостановленные конфиги разом и квоту не проверяет — без этого + правила старое (большее) количество тихо вернулось бы в обход новой квоты. +- Проверка/резервирование — под `AdvisoryLock` (по `UserId`, тот же ключ, что и у + `CreateVpnConfigCommandHandler`) — не даёт гонки с параллельным созданием конфига. Сам отзыв + конфигов (вызов гейтвея `RemoveClientAsync`) — вне лока, после коммита квоты, тем же общим шагом, + что и у `RevokeVpnConfigCommandHandler` (`VpnConfigRevocation.RevokeAsync` — не звонит в гейтвей, + если инбаунд уже `!IsAvailable`, не помечает `Revoked`, если гейтвей упал). + +**`AdminSetUserPlanCommand`** (`PATCH /api/admin/users/{id}/plan`) — прямой оверрайд админом, +рядом с прямой сменой роли: **без** пикера конфигов при понижении (грандфазеринг — как раньше при +понижении роли: лишние конфиги не трогаются, новые блокируются, пока не войдёт в квоту) и **без** +доплаты (тот же принцип, что и у прямой смены роли — осознанный инструмент админа, может быть +использован как поощрение). `CustomConfigCount = -1` (безлимит) допустим только если целевой +пользователь уже в роли `admin` (`Plans.UnlimitedOnlyForAdmin` иначе). + ### PricingSettings — глобальная справочная цена конфига Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с @@ -376,15 +442,17 @@ UI **настойчиво напоминает** привязать его (ед | `UpdatedAt` | `DateTimeOffset` | | Все три поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка × -число_месяцев × AppRole.MaxConfigs`, считается на фронте (таблица ролей в админке), нигде не +число_месяцев × количество_конфигов` (тарифа `Plan` или ручного ввода — см. `Plan` выше), считается +на фронте (страница тарифов в админке `/admin/plans` и страница смены тарифа `/plan`), нигде не хранится: -- 3 месяца = `PricePerConfigPerQuarter × 3 × MaxConfigs` -- полгода = `PricePerConfigPerHalfYear × 6 × MaxConfigs` -- год = `PricePerConfigPerYear × 12 × MaxConfigs` +- 3 месяца = `PricePerConfigPerQuarter × 3 × ConfigCount` +- полгода = `PricePerConfigPerHalfYear × 6 × ConfigCount` +- год = `PricePerConfigPerYear × 12 × ConfigCount` -Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес, -1200₽/полгода, 2400₽/год (линейный рост, скидки за тариф нет). Для ролей с `MaxConfigs = -1` -(unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано». +Например, тариф с `ConfigCount=3` и одинаковой ставкой 200₽/мес на всех трёх периодах → 600₽/3мес, +1200₽/полгода, 2400₽/год (линейный рост, скидки за период нет — скидка за объём отдельная, см. +`PricingDiscountTier` ниже). Для `ConfigQuota = -1` (unlimited, только `admin`) итог не считается — +отображается как «не задано». **Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и @@ -394,46 +462,47 @@ PricePerConfigPerQuarter × 3` и `PricePerConfigPerYear × 12 ≥ PricePerConfi PricePerConfigPerQuarter × 3`). `GET/PUT /api/admin/pricing` — только `admin` (редактирование). `GET /api/support/pricing` — то же -чтение, но доступно любому активированному пользователю (не `admin`-эндпоинт) — используется в -диалоге заявки на роль, чтобы показать ориентировочную стоимость выбранной/предлагаемой роли, с -пометкой, что цены пока ознакомительные. Это два разных Query (`GetPricingSettingsQuery` в -`Admin/Pricing`, `GetSupportPricingQuery` в `Support`) над одним и тем же общим `PricingSettingsDto` -(`Common/Interfaces`) — по аналогии с `ListRolesQuery`/`ListSelectableRolesQuery` для ролей. В отличие -от `RoleDto`, у `PricingSettingsDto` нет чувствительных per-роль данных, поэтому шарить DTO между -admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, -если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше). +чтение, но доступно любому активированному пользователю (не `admin`-эндпоинт) — используется +страницей смены тарифа (`/plan`), чтобы показать ориентировочную стоимость каждого варианта, с +пометкой, что цены пока ознакомительные (эндпоинт исторически называется `support/pricing` — раньше +использовался диалогом заявки на роль, сейчас переиспользован страницей `/plan`, переименовывать не +стали). Это два разных Query (`GetPricingSettingsQuery` в `Admin/Pricing`, `GetSupportPricingQuery` в +`Support`) над одним и тем же общим `PricingSettingsDto` (`Common/Interfaces`). У `PricingSettingsDto` +нет чувствительных данных, поэтому шарить DTO между admin- и user-facing путями безопасно. +Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, если таблица пуста) и заново после +полного сброса панели (см. «Полный сброс панели» выше). #### PricingDiscountTier — скидка за объём (лесенка порогов) -Стимул брать роль с бОльшей квотой конфигов разом: плоская таблица (не навигационная коллекция — -см. конвенцию проекта на TicketComment) с FK на `PricingSettingsId`, глобальная, не привязана к -конкретной роли — как и сам `PricingSettings`. +Стимул брать тариф с бОльшим количеством конфигов разом: плоская таблица (не навигационная +коллекция — см. конвенцию проекта на TicketComment) с FK на `PricingSettingsId`, глобальная, не +привязана к конкретному тарифу — как и сам `PricingSettings`. | Поле | Тип | Заметки | | ------------------- | -------- | ------------------------------------------------------------------ | | `Id` | `Guid` | PK | | `PricingSettingsId` | `Guid` | FK → PricingSettings | -| `MinConfigs` | `int` | Порог: скидка действует при `AppRole.MaxConfigs >= MinConfigs` | +| `MinConfigs` | `int` | Порог: скидка действует при `количество_конфигов >= MinConfigs` | | `DiscountPercent` | `int` | Скидка в процентах от итоговой цены периода, 1–99 | Действует **наивысший подходящий порог** (не суммируется с другими) — `PricingDiscount.ResolvePercent` -(`Domain/Pricing`): из тиров с `MinConfigs <= MaxConfigs` берётся тот, у которого `MinConfigs` -максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` роль с `MaxConfigs=8` получает 10%, а не 15%. +(`Domain/Pricing`): из тиров с `MinConfigs <= количество_конфигов` берётся тот, у которого `MinConfigs` +максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` тариф на 8 конфигов получает 10%, а не 15%. Скидка применяется к уже посчитанному итогу периода: `PricingDiscount.Apply(итог, процент)`, округление -до целого рубля (`MidpointRounding.AwayFromZero`). Роли с `MaxConfigs = -1` (unlimited) скидку не -получают — как и обычный расчёт цены, для них итог не считается. +до целого рубля (`MidpointRounding.AwayFromZero`). `ConfigQuota = -1` (unlimited, только `admin`) скидку +не получает — как и обычный расчёт цены, для него итог не считается. **Инвариант**: `UpdatePricingSettingsCommandValidator` требует уникальности порогов и прогрессивности -лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять роль с -бОльшей квотой может оказаться менее выгодно, что противоречит смыслу скидки за объём). +лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять +бОльшее количество конфигов может оказаться менее выгодно, что противоречит смыслу скидки за объём). Применяется в двух местах, зеркалящих друг друга: реальная оплата (`CreatePaymentRequestCommandHandler` — `AmountSnapshot` уже с учётом скидки) и ознакомительная оценка (`PricingSettingsDto.DiscountTiers` + `resolveDiscountPercent`/`applyDiscount` на фронте, `frontend/src/shared/lib/pricing.ts`) — используется -и в списке ролей в админке (`admin/roles.tsx`), и в оценке стоимости при смене роли тикетом -(`CreateRoleRequestDialog.tsx`). `UpdatePricingSettingsCommand` при сохранении полностью заменяет набор -тиров (удаляет старые, вставляет новые) — операция редкая (правит только `admin`), сложность -инкрементального diff не оправдана. +и в списке тарифов в админке (`admin/plans.tsx`), и на странице смены тарифа (`/plan`). +`UpdatePricingSettingsCommand` при сохранении полностью заменяет набор тиров (удаляет старые, +вставляет новые) — операция редкая (правит только `admin`), сложность инкрементального diff не +оправдана. ### Billing — подписка по сроку @@ -467,79 +536,82 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки **`Kind.Subscription`** на пользователя — инвариант проверяется в `CreatePaymentRequestCommandHandler` и не распространяется на -`Kind.RoleChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку. +`Kind.PlanChangeTopUp` (см. ниже) — доплата не должна мешать оформить/продлить обычную подписку. | Поле | Тип | Заметки | | ------------------ | ----------------------- | ------------------------------------------------------------ | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (заявитель) | -| `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `RoleChangeTopUp` (доплата за апгрейд роли, см. ниже) | -| `Period` | `PaymentPeriod?` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес). `null` для `Kind.RoleChangeTopUp` — доплата не привязана к тарифному периоду | -| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания. Для `Subscription`: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Для `RoleChangeTopUp`: см. `RoleChangeTopUp.Compute` ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки | +| `Kind` | `PaymentRequestKind` | `Subscription` (оплата за период) / `PlanChangeTopUp` (доплата за увеличение тарифа, см. ниже) | +| `Period` | `PaymentPeriod?` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес). `null` для `Kind.PlanChangeTopUp` — доплата не привязана к тарифному периоду | +| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания. Для `Subscription`: `ставка PricingSettings за период × ConfigQuota пользователя × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Для `PlanChangeTopUp`: см. `PlanChangeTopUp.Compute` ниже. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки | | `Status` | `PaymentRequestStatus` | `AwaitingPayment` → `AwaitingConfirmation` → `Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` | | `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) | | `CreatedAt` | `DateTimeOffset` | | -Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле — +`ConfigQuota = -1` (unlimited, только `admin`) не поддерживает биллинг по формуле — `CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`; та же логика в -`RoleChangeTopUp.Compute` (`null`, доплата не считается). +`PlanChangeTopUp.Compute` (`null`, доплата не считается) — впрочем, для `admin` биллинг и не +применяется (`AppRole.BillingEnabled` для него запрещён в принципе). **Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`): -- `Create(userId, period, amount)` (`Kind.Subscription`) / `CreateRoleChangeTopUp(userId, amount)` - (`Kind.RoleChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь +- `Create(userId, period, amount)` (`Kind.Subscription`) / `CreatePlanChangeTopUp(userId, amount)` + (`Kind.PlanChangeTopUp`) → `AwaitingPayment`, показываются реквизиты `BillingSettings`. Пользователь может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки. - `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`, админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}` (текст - уведомления зависит от `Kind` — период или «доплата за смену роли», см. `TelegramNotifier`). + уведомления зависит от `Kind` — период или «доплата за смену тарифа», см. `TelegramNotifier`). - `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и `AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку). Для `Kind.Subscription` `Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) + период` (не теряет уже оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги, приостановленные за неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет - `ExpiresAt` на всех конфигах пользователя. Для `Kind.RoleChangeTopUp` `Confirm` **только** переводит + `ExpiresAt` на всех конфигах пользователя. Для `Kind.PlanChangeTopUp` `Confirm` **только** переводит заявку в `Confirmed` — `BillingPaidUntil` не трогает (это не покупка времени, а закрытие долга за уже - выданный апгрейд) — см. `ConfirmPaymentRequestCommandHandler`. + выданное увеличение квоты) — см. `ConfirmPaymentRequestCommandHandler`. -#### RoleChangeTopUp — доплата при апгрейде роли с активным периодом +#### PlanChangeTopUp — доплата при увеличении тарифа с активным периодом -Пользователь с активным `BillingPaidUntil` меняет роль (тикетом `SupportTicket.RoleRequest`, -`ApproveRoleRequestCommandHandler`) на более дорогую — по-хорошему должен доплатить разницу, а не -доиграть апгрейд бесплатно до конца уже оплаченного срока. **Роль меняется сразу** (не блокируется -ожиданием оплаты); доплата решается отдельно через обычный флоу `PaymentRequest` -(`Kind.RoleChangeTopUp`) — тем же путём, что и обычная оплата: сайт (`billing.tsx`, -`PaymentRequestPanel`) или Telegram (`pay:approve`/`pay:reject`). +Пользователь с активным `BillingPaidUntil` увеличивает тариф (`ChangePlanCommand`, самостоятельно, +без подтверждения админа) — по-хорошему должен доплатить разницу, а не доиграть увеличенную квоту +бесплатно до конца уже оплаченного срока. **Квота меняется сразу** (не блокируется ожиданием +оплаты); доплата решается отдельно через обычный флоу `PaymentRequest` (`Kind.PlanChangeTopUp`) — +тем же путём, что и обычная оплата: сайт (`billing.tsx`, `PaymentRequestPanel`) или Telegram +(`pay:approve`/`pay:reject`). -Сумма — `RoleChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/RoleChangeTopUp.cs`), чистая +Сумма — `PlanChangeTopUp.Compute` (`backend/src/PnvPanel.Domain/Billing/PlanChangeTopUp.cs`), чистая функция без I/O: -1. Месячная стоимость роли = `PricingSettings.PricePerConfigPerQuarter × MaxConfigs`, затем скидка по - лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же формула и тот же - базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от того, за какой период - пользователь платил на самом деле — упрощение, чтобы не вводить отдельное понятие «дневная ставка - по фактическому тарифу». -2. Разница месячных стоимостей новой и старой роли, поделённая на 30 (условный «месяц» для +1. Месячная стоимость тарифа = `PricingSettings.PricePerConfigPerQuarter × количество_конфигов`, + затем скидка по лесенке `PricingDiscountTier` (`PricingDiscount.ResolvePercent`/`Apply`) — та же + формула и тот же базовый (квартальный/минимальный) тариф, что у обычной оплаты, независимо от + того, за какой период пользователь платил на самом деле — упрощение, чтобы не вводить отдельное + понятие «дневная ставка по фактическому тарифу». +2. Разница месячных стоимостей новой и старой квоты, поделённая на 30 (условный «месяц» для проратирования) и умноженная на число оставшихся до `BillingPaidUntil` дней — округление до целого рубля (`MidpointRounding.AwayFromZero`). -3. `null` (доплата не создаётся), если: новая роль не дороже старой (в т.ч. понижение — остаётся - грандфазеринг, без доплаты и без возврата), оплаченный период уже истёк, либо старая/новая роль без - лимита конфигов (`MaxConfigs = -1`, цена не считается). +3. `null` (доплата не создаётся), если: новая квота не больше старой (понижение — см. `ChangePlanCommand` + выше, требует пикера конфигов, а не доплаты), оплаченный период уже истёк, либо старая/новая квота + без лимита (`ConfigQuota = -1`, цена не считается — на практике не встречается вне `admin`, для + которого биллинг вообще не применяется). -Применяется только к самостоятельной заявке на роль (`ApproveRoleRequestCommandHandler`) — админская -прямая смена роли (`PATCH /api/admin/users/{id}/role`, `UserManageDialog`) доплату не создаёт: это -осознанный инструмент админа, который может быть применён как поощрение. +Применяется только к самостоятельной смене тарифа (`ChangePlanCommandHandler`) — админский прямой +оверрайд (`PATCH /api/admin/users/{id}/plan`, `AdminSetUserPlanCommandHandler`) доплату не создаёт: +это осознанный инструмент админа, который может быть применён как поощрение (тот же принцип, что и +у прямой смены роли). **Известное ограничение**: `GetMyBillingStatusQueryHandler` отдаёт только одну `activeRequest` — -если у пользователя одновременно есть активная `Subscription`-заявка и `RoleChangeTopUp` (редкий -случай: роль сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна из -них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не блокирует -основной сценарий. +если у пользователя одновременно есть активная `Subscription`-заявка и `PlanChangeTopUp` (редкий +случай: тариф сменили, пока уже шла обычная оплата), на странице `/billing` будет видна только одна +из них (обе видны в админке и обе решаемы через Telegram). Не устранено — узкий edge case, не +блокирует основной сценарий. ### BillingService — приостановка за неуплату (фоновая джоба) `Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`): - есть **Subscription**-`PaymentRequest` в статусе `AwaitingConfirmation` → не гасить, а защитить (`BillingConfigResumer.ProtectPendingConfigsAsync`, см. ниже) и пропустить остальную обработку тика. - `RoleChangeTopUp` в этот фильтр намеренно не входит — доплата за апгрейд роли не должна спасать от - приостановки за реально просроченную подписку; + `PlanChangeTopUp` в этот фильтр намеренно не входит — доплата за увеличение тарифа не должна спасать + от приостановки за реально просроченную подписку; - `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить (`BillingConfigResumer.SuspendConfigsAsync`), `AppUser.BillingSuspended = true`, Telegram-уведомление пользователю, `AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже @@ -679,49 +751,43 @@ Application-хендлере поверх результата `IIdentityService refresh-токена — иначе блокировка обходилась бы passwordless-входом/уже выданным refresh-токеном. ### SupportTicket — обращение в поддержку -Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли — -кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N -дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится отдельным полем — -это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции. +Два вида: `BugReport` (свободная форма, с вложениями) и `ExtensionRequest` (продление оплаченного +периода на N дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится +отдельным полем — это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в +одной операции. + +> До введения `Plan` (см. выше) существовал третий вид — `RoleRequest` (самостоятельная заявка на +> роль/квоту, с одобрением админом). С переходом квоты конфигов на `AppUser.ConfigQuota` и +> самостоятельной сменой тарифа без подтверждения (`ChangePlanCommand`) необходимость в этом виде +> отпала — роль меняет только админ напрямую (`PATCH /api/admin/users/{id}/role`). | Поле | Тип | Заметки | | ------------------- | ----------------- | ---------------------------------------------------------------- | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (автор) | -| `Type` | `TicketType` | `BugReport` / `RoleRequest` / `ExtensionRequest` | +| `Type` | `TicketType` | `BugReport` / `ExtensionRequest` | | `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` | -| `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли | -| `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли | -| `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) | -| `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) | | `RequestedDays` | `int?` | Заполнено для `ExtensionRequest` — сколько дней просит пользователь (1–365) | | `CreatedAt` | `DateTimeOffset` | | -Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId` -и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками -(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой. Аналогично -`RequestedDays` заполняется только фабрикой `CreateExtensionRequest`. -- `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application - (`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем - в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`. Для - `ExtensionRequest` — `ApproveExtensionRequestCommandHandler` продлевает `AppUser.BillingPaidUntil` - на `RequestedDays` (от `max(текущий, сейчас)`, как и у `PaymentRequest`) и возвращает в `Active` - конфиги, приостановленные за неуплату (`BillingConfigResumer`, тот же helper, что и у подтверждения - оплаты и гифт-дней от админа). -- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/ - `ExtensionRequest` — отклонение. +Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedDays` +заполняется только фабрикой `CreateExtensionRequest`. +- `Resolve()` — только из `Open`. Для `ExtensionRequest` — `ApproveExtensionRequestCommandHandler` + продлевает `AppUser.BillingPaidUntil` на `RequestedDays` (от `max(текущий, сейчас)`, как и у + `PaymentRequest`) и возвращает в `Active` конфиги, приостановленные за неуплату + (`BillingConfigResumer`, тот же helper, что и у подтверждения оплаты и гифт-дней от админа). +- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `ExtensionRequest` — + отклонение. - `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается. -- **`approve`/`reject` — единственный путь решить `RoleRequest`/`ExtensionRequest`.** Общие - `/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этих двух типах +- **`approve-extension`/`reject-extension` — единственный путь решить `ExtensionRequest`.** Общие + `/admin/support/tickets/{id}/resolve|close` (для произвольного `BugReport`) на этом типе возвращают `OnlyBugReportCanBeResolvedDirectly`/`OnlyBugReportCanBeClosedDirectly` без изменения - статуса — иначе `resolve` обходил бы `ApproveRoleRequestCommandHandler`/ - `ApproveExtensionRequestCommandHandler` и переводил тикет в `Resolved`, так и не выдав роль/дни. - По той же причине `Reopen()` на `RoleRequest`/`ExtensionRequest` тоже запрещён - (`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на роль не имеет осмысленного - действия (роль/дни уже выданы, откатывать их не пытаемся). -- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`) - и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`) - на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`. + статуса — иначе `resolve` обходил бы `ApproveExtensionRequestCommandHandler` и переводил тикет в + `Resolved`, так и не начислив дни. По той же причине `Reopen()` на `ExtensionRequest` тоже + запрещён (`OnlyBugReportCanBeReopened`) — переоткрытие уже решённой заявки на продление не имеет + осмысленного действия (дни уже выданы, откатывать их не пытаемся). +- Не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`) на + пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`. Баг-репорты такого ограничения не имеют. - Создать `ExtensionRequest` может только пользователь с billing-ролью (`CurrentUserProfile.BillingEnabled`) — иначе `Billing.NotEnabled`. @@ -730,10 +796,9 @@ refresh-токена — иначе блокировка обходилась б ролевой проверкой, без завязки на активацию. - Resolve/close/reject **собственного** тикета админом разрешены — они не трогают роль, риска нет (запрет ломал бы самообслуживание: тикет единственного админа застревал бы в `Open` навсегда, убрать - некому). Единственное действие с реальным риском — approve заявки на роль, потому что оно меняет - роль заявителя; его самостоятельная защита не нужна — она уже есть на уровень ниже, см. `AppRole` - (`RoleErrors.CannotRemoveLastAdmin`), и одинаково работает что для approve своей заявки, что для - прямой смены роли через `/admin/users`. + некому). Смена роли остаётся отдельным доверенным действием (`PATCH /admin/users/{id}/role`), не + связанным с тикетами, — её единственная защита (`RoleErrors.CannotRemoveLastAdmin`) живёт на уровне + `AppRole` и не пересекается с этим флоу. - `Closed`-тикеты не удаляются автоматически — админ может подчистить их вручную (вкладка «Обслуживание», `DELETE /api/admin/maintenance/tickets/closed`), это удаляет и `TicketComment`/ `TicketAttachment` (+ файлы на диске), необратимо. @@ -790,7 +855,7 @@ enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed } enum ActivationStatus { Pending, Approved, Rejected } enum AuditSource { Web, Telegram, System } enum OsPlatform { IOS, Android, Windows, MacOS, Linux } -enum TicketType { BugReport, RoleRequest, ExtensionRequest } +enum TicketType { BugReport, ExtensionRequest } enum TicketStatus { Open, Resolved, Closed } ``` @@ -818,10 +883,8 @@ enum TicketStatus { Open, Resolved, Closed } | `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` | | `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу | | `CreateBugReportTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — превью текста + кнопка-ссылка на сайт | -| `CreateRoleRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» | | `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам | -| `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу | -| `RejectRoleRequestCommandHandler` | `AuditLog` (`RoleRequestRejected`); Telegram-DM владельцу | +| `ChangePlanCommandHandler` | Меняет `AppUser.ConfigQuota`/`PlanId`, при понижении отзывает выбранные конфиги в 3x-ui; `AuditLog` (`PlanChanged`); при доплате — создаёт `PaymentRequest` (`PlanChangeTopUp`) + Telegram-DM владельцу | | `CreateExtensionRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» | | `ApproveExtensionRequestCommandHandler` | Продлевает `BillingPaidUntil`, возвращает приостановленные конфиги; `AuditLog` (`ExtensionRequestApproved`); Telegram-DM владельцу | | `RejectExtensionRequestCommandHandler` | `AuditLog` (`ExtensionRequestRejected`); Telegram-DM владельцу | diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 573c59d..ad16710 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -16,8 +16,10 @@ (`AsNoTracking` + `Select`). - **БД**: PostgreSQL. - **Auth**: ASP.NET Core Identity + JWT (access, короткий TTL) + refresh (httpOnly cookie, ротация). -- **RBAC**: динамические роли с квотой (`AppRole.MaxConfigs`). Доступ к инбаундам — по ролям - (`Inbound.AllowedRoles`). Квота на число конфигов — на роли, а не на тариф. +- **RBAC**: динамические роли (`AppRole` — устройства `MaxIpLimit`, доступ к инбаундам + `Inbound.AllowedRoles`, флаг `BillingEnabled`). Квота на число конфигов — на пользователе + (`AppUser.ConfigQuota`), задаётся самообслуживаемым тарифом (`Plan`), не ролью; безлимит (`-1`) + зарезервирован за ролью `admin`. - **Активация пользователей**: `AppUser.IsActivated` + `ActivationRequest` (с комментарием). Неактивированный не создаёт конфиги; решение принимает админ на сайте или в Telegram — одними и теми же CQRS-командами. diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 31bd69c..1c58d50 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -33,12 +33,12 @@ Telegram-бот — **второй канал доставки** (presentation- Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl` (пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`). -8. **Админ: обработка заявок на роль поддержки** — при новой заявке (`SupportTicket.Type == - RoleRequest`) админ получает сообщение с описанием (существующая роль либо параметры новой) и - обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без захода на сайт** — одобрение - создаёт роль (если новая) и назначает её пользователю той же командой, что и на сайте. Баг-репорты/ - предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий (переписка и - вложения удобнее там). +8. **Админ: обработка заявок на продление поддержки** — при новой заявке + (`SupportTicket.Type == ExtensionRequest`, только для billing-ролей) админ получает сообщение с + числом запрошенных дней и обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без + захода на сайт** — одобрение продлевает `BillingPaidUntil` той же командой, что и на сайте. + Баг-репорты/предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий + (переписка и вложения удобнее там). **Не реализовано:** - QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке). @@ -49,6 +49,7 @@ Telegram-бот — **второй канал доставки** (presentation- просмотр списка и показ существующей ссылки по кнопке). - Баг-репорты/предложения тикетов поддержки **не решаются из бота** (только уведомление-ссылка) — ответы, вложения, resolve/close только на сайте. +- Смена тарифа (`ChangePlanCommand`) — только на сайте (`/plan`), в боте не решается. ## Размещение в архитектуре @@ -182,23 +183,8 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl (`InlineKeyboardButton.WithUrl`, не callback) — тап открывает страницу тикета в браузере. 3. Дальше — только на сайте: переписка, вложения, resolve/close. -**Заявка на роль** — полностью решается в Telegram: -1. `CreateRoleRequestTicketCommandHandler` вызывает - `ITelegramNotifier.NotifyAdminsRoleRequestCreatedAsync(ticketId, userName, roleDescription, justification, ct)` - — `roleDescription` уже готовая строка (имя существующей роли либо «новая роль «X» (конфигов: N, IP: M)»). -2. Сообщение с кнопками **«✅ Одобрить» / «❌ Отклонить»** (callback `rrq:approve:{id}`/`rrq:reject:{id}` - — тот же 3-частный формат `prefix:action:guid`, что и `act:*` для активации). -3. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`, тот же, что для активации) → - `ApproveRoleRequestCommand`/`RejectRoleRequestCommand` (те же команды, что дёргает - `POST /api/admin/support/tickets/{id}/approve|reject` на сайте). При одобрении — если роль новая, - сперва создаётся `AppRole`, затем в любом случае назначается пользователю; тикет переходит в - `Resolved`/`Closed`. Нажавшему админу — короткое подтверждение, исходное сообщение редактируется - (дописывается статус), как и у `act:*`. -4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на - роль отклонена.». - -**Заявка на продление** (`ExtensionRequest`, только для billing-ролей) — тот же паттерн, что и заявка -на роль, инлайн-кнопки с префиксом `erq:`: +**Заявка на продление** (`ExtensionRequest`, только для billing-ролей) — полностью решается в +Telegram, инлайн-кнопки с префиксом `erq:`: 1. `CreateExtensionRequestTicketCommandHandler` вызывает `ITelegramNotifier.NotifyAdminsExtensionRequestCreatedAsync(ticketId, userName, requestedDays, justification, ct)`. 2. Кнопки **«✅ Одобрить» / «❌ Отклонить»** (callback `erq:approve:{id}`/`erq:reject:{id}`). @@ -213,7 +199,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl Только для ролей с `AppRole.BillingEnabled` (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)). Заявка (`PaymentRequest`) заводится и решается частично на сайте, частично в Telegram — симметрично -заявке на роль: +заявке на продление: 1. Пользователь создаёт заявку и жмёт «Я оплатил» на сайте (`/billing`) — бот в это не вовлечён, кроме опциональной кнопки «Отправить реквизиты в Telegram» (DM самому себе для удобства, статус @@ -221,11 +207,11 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl 2. `MarkPaymentSentCommandHandler` вызывает `ITelegramNotifier.NotifyAdminsPaymentRequestedAsync(requestId, userName, period, amount, ct)`. 3. Сообщение с кнопками **«✅ Подтвердить» / «❌ Отклонить»** (callback `pay:approve:{id}`/`pay:reject:{id}` - — тот же 3-частный формат, что и `rrq:*`/`act:*`). + — тот же 3-частный формат, что и `erq:*`/`act:*`). 4. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`) → `ConfirmPaymentRequestCommand`/ `RejectPaymentRequestCommand` (те же команды, что дёргает `POST /api/admin/billing/requests/{id}/confirm|reject` на сайте). Подтверждение продлевает `AppUser.BillingPaidUntil` и возвращает приостановленные - конфиги в `Active`. Исходное сообщение редактируется (дописывается статус), как у `rrq:*`/`act:*`. + конфиги в `Active`. Исходное сообщение редактируется (дописывается статус), как у `erq:*`/`act:*`. 5. Пользователю (если Telegram привязан) — DM «✅ Оплата подтверждена. Доступ продлён до {дата}.» / «❌ Заявка на оплату отклонена.». @@ -261,7 +247,6 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl | «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) | | «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env | | `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env | -| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env | | «✅ Одобрить»/«❌ Отклонить» (`erq:*`) | (admin) решение по заявке на продление — продлевает `BillingPaidUntil` | админ по env | | «✅ Подтвердить»/«❌ Отклонить» (`pay:*`) | (admin) решение по заявке на оплату — продлевает `BillingPaidUntil` | админ по env | | «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env | diff --git a/frontend/src/features/admin/plans/PlanFormDialog.tsx b/frontend/src/features/admin/plans/PlanFormDialog.tsx new file mode 100644 index 0000000..035a1b6 --- /dev/null +++ b/frontend/src/features/admin/plans/PlanFormDialog.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from '@/shared/ui/toast-store' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog' +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 { AdminPlanDto } from '@/shared/api/types' +import { createPlan, updatePlan } from './api' + +export function PlanFormDialog({ + plan, + open, + onOpenChange, +}: { + plan?: AdminPlanDto + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [internalOpen, setInternalOpen] = useState(false) + const [name, setName] = useState(plan?.name ?? '') + const [configCount, setConfigCount] = useState(String(plan?.configCount ?? 3)) + const [sortOrder, setSortOrder] = useState(String(plan?.sortOrder ?? 0)) + const [isEnabled, setIsEnabled] = useState(plan?.isEnabled ?? true) + + const isControlled = open !== undefined + const dialogOpen = isControlled ? open : internalOpen + const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen + + const mutation = useMutation({ + mutationFn: () => + plan + ? updatePlan(plan.id, name.trim(), Number(configCount), Number(sortOrder), isEnabled) + : createPlan(name.trim(), Number(configCount), Number(sortOrder)), + onSuccess: async () => { + toast.success(plan ? t('admin.plans.updated') : t('admin.plans.created')) + await queryClient.invalidateQueries({ queryKey: ['admin-plans'] }) + setDialogOpen(false) + }, + onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), + }) + + const canSubmit = name.trim() && Number(configCount) >= 1 + + return ( + + {!isControlled && ( + + + + )} + + + {plan ? plan.name : t('admin.plans.create')} + +
{ + e.preventDefault() + if (canSubmit) mutation.mutate() + }} + > +
+ + setName(e.target.value)} required /> +
+
+ + setConfigCount(e.target.value)} + /> +
+
+ + setSortOrder(e.target.value)} /> +
+ {plan && ( + + )} + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/plans/api.ts b/frontend/src/features/admin/plans/api.ts new file mode 100644 index 0000000..7878643 --- /dev/null +++ b/frontend/src/features/admin/plans/api.ts @@ -0,0 +1,24 @@ +import { apiRequest } from '@/shared/api/client' +import type { AdminPlanDto } from '@/shared/api/types' + +export function listAdminPlans() { + return apiRequest('/admin/plans') +} + +export function createPlan(name: string, configCount: number, sortOrder: number) { + return apiRequest('/admin/plans', { + method: 'POST', + body: { name, configCount, sortOrder }, + }) +} + +export function updatePlan(id: string, name: string, configCount: number, sortOrder: number, isEnabled: boolean) { + return apiRequest(`/admin/plans/${id}`, { + method: 'PUT', + body: { name, configCount, sortOrder, isEnabled }, + }) +} + +export function deletePlan(id: string) { + return apiRequest(`/admin/plans/${id}`, { method: 'DELETE' }) +} diff --git a/frontend/src/features/admin/roles/RoleFormDialog.tsx b/frontend/src/features/admin/roles/RoleFormDialog.tsx index 07626e4..ae731ed 100644 --- a/frontend/src/features/admin/roles/RoleFormDialog.tsx +++ b/frontend/src/features/admin/roles/RoleFormDialog.tsx @@ -24,7 +24,6 @@ export function RoleFormDialog({ const { t } = useTranslation() const queryClient = useQueryClient() const [name, setName] = useState(role?.name ?? '') - const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3)) const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2)) const [billingEnabled, setBillingEnabled] = useState(role?.billingEnabled ?? false) const [internalOpen, setInternalOpen] = useState(false) @@ -52,14 +51,13 @@ export function RoleFormDialog({ const mutation = useMutation({ mutationFn: () => role - ? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit), billingEnabled) - : createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit), billingEnabled), + ? updateRole(role.id, Number(maxIpLimit), billingEnabled) + : createRole(name.trim(), Number(maxIpLimit), billingEnabled), onSuccess: async () => { toast.success(role ? t('admin.roles.updated') : t('admin.roles.created')) await queryClient.invalidateQueries({ queryKey: ['admin-roles'] }) setDialogOpen(false) setName('') - setMaxConfigs('3') setMaxIpLimit('2') setBillingEnabled(false) }, @@ -90,11 +88,6 @@ export function RoleFormDialog({ setName(e.target.value)} required /> )} -
- - setMaxConfigs(e.target.value)} /> -

{t('admin.roles.maxConfigsHint')}

-
setMaxIpLimit(e.target.value)} /> diff --git a/frontend/src/features/admin/roles/api.ts b/frontend/src/features/admin/roles/api.ts index 2c34073..5abf0ee 100644 --- a/frontend/src/features/admin/roles/api.ts +++ b/frontend/src/features/admin/roles/api.ts @@ -5,17 +5,17 @@ export function listRoles() { return apiRequest('/admin/roles') } -export function createRole(name: string, maxConfigs: number, maxIpLimit: number, billingEnabled: boolean) { +export function createRole(name: string, maxIpLimit: number, billingEnabled: boolean) { return apiRequest('/admin/roles', { method: 'POST', - body: { name, maxConfigs, maxIpLimit, billingEnabled }, + body: { name, maxIpLimit, billingEnabled }, }) } -export function updateRole(id: string, maxConfigs: number, maxIpLimit: number, billingEnabled: boolean) { +export function updateRole(id: string, maxIpLimit: number, billingEnabled: boolean) { return apiRequest(`/admin/roles/${id}`, { method: 'PUT', - body: { maxConfigs, maxIpLimit, billingEnabled }, + body: { maxIpLimit, billingEnabled }, }) } diff --git a/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx b/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx index b891dbc..990a09a 100644 --- a/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx +++ b/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx @@ -12,11 +12,9 @@ import { TicketAttachmentImage } from '@/features/support/TicketAttachmentImage' import { addAdminComment, approveExtensionRequest, - approveRoleRequest, closeTicket, getAdminTicket, rejectExtensionRequest, - rejectRoleRequest, resolveTicket, } from './api' @@ -66,8 +64,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: }) const approveMutation = useMutation({ - mutationFn: () => - data?.type === 'ExtensionRequest' ? approveExtensionRequest(ticketId) : approveRoleRequest(ticketId), + mutationFn: () => approveExtensionRequest(ticketId), onSuccess: async () => { toast.success(t('admin.support.approved')) await invalidate() @@ -76,8 +73,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: }) const rejectMutation = useMutation({ - mutationFn: () => - data?.type === 'ExtensionRequest' ? rejectExtensionRequest(ticketId) : rejectRoleRequest(ticketId), + mutationFn: () => rejectExtensionRequest(ticketId), onSuccess: async () => { toast.success(t('admin.support.rejected')) await invalidate() @@ -104,18 +100,6 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: {data && (
- {data.type === 'RoleRequest' && ( -
- {data.requestedRoleName - ? t('support.requestedExistingRole', { role: data.requestedRoleName }) - : t('support.requestedNewRole', { - name: data.proposedRoleName, - configs: data.proposedMaxConfigs, - ip: data.proposedMaxIpLimit, - })} -
- )} - {data.type === 'ExtensionRequest' && (
{t('support.requestedExtension', { days: data.requestedDays })} @@ -143,7 +127,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: {data.status !== 'Closed' && (
- {(data.type === 'RoleRequest' || data.type === 'ExtensionRequest') && data.status === 'Open' ? ( + {data.type === 'ExtensionRequest' && data.status === 'Open' ? ( <> - - - - {t('support.requestRole')} - -
{ - e.preventDefault() - if (canSubmit) mutation.mutate() - }} - > -
- - -
- - {mode === 'existing' ? ( -
- - -
- ) : ( - <> -
- - setNewRoleName(e.target.value)} /> -
-
- - setNewRoleMaxConfigs(e.target.value)} - /> -
-
- - setNewRoleMaxIpLimit(e.target.value)} - /> -
- - )} - - {showPricing && ( -
-

{t('support.pricingTitle')}

-

{t('support.pricingQuarter', { price: totalPrice(pricingQuery.data?.pricePerConfigPerQuarter, 3) })}

-

{t('support.pricingHalfYear', { price: totalPrice(pricingQuery.data?.pricePerConfigPerHalfYear, 6) })}

-

{t('support.pricingYear', { price: totalPrice(pricingQuery.data?.pricePerConfigPerYear, 12) })}

-

{t('support.pricingDisclaimer')}

-
- )} - -
- -