Implement billing functionality and enhance role management
- Introduced billing capabilities, allowing users to request payments for subscription periods (3/6/12 months) with admin approval via Telegram. - Updated role management to include a `BillingEnabled` property, preventing billing for admin roles. - Enhanced the `CreateRoleCommand` and `UpdateRoleCommand` to accept billing parameters, ensuring proper handling during role creation and updates. - Added new endpoints for billing management and integrated billing checks into VPN config creation to enforce payment requirements. - Updated related services, models, and tests to support the new billing features, ensuring comprehensive coverage and functionality. - Enhanced documentation to reflect the new billing processes and role management changes.
This commit is contained in:
@@ -10,8 +10,9 @@
|
|||||||
[`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и хранит проекцию домена в PostgreSQL.
|
[`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и хранит проекцию домена в PostgreSQL.
|
||||||
Живые обновления — SignalR. Поставка — **единый Docker-образ** (фронт+бек+бот) + PostgreSQL в compose.
|
Живые обновления — SignalR. Поставка — **единый Docker-образ** (фронт+бек+бот) + PostgreSQL в compose.
|
||||||
|
|
||||||
> Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Осознанно не реализовано:
|
> Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Есть опциональный биллинг
|
||||||
> тарифы, лимиты трафика/срока на конфиг, полное самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md).
|
> (подписка по сроку, per-роль). Осознанно не реализовано: лимиты трафика на конфиг, полное
|
||||||
|
> самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md).
|
||||||
|
|
||||||
## Документация (single source of truth)
|
## Документация (single source of truth)
|
||||||
|
|
||||||
@@ -92,6 +93,12 @@
|
|||||||
админский путь дополнительно пишет `AuditLog` (`UserDeleted`) и шлёт Telegram-DM.
|
админский путь дополнительно пишет `AuditLog` (`UserDeleted`) и шлёт Telegram-DM.
|
||||||
- **Аудит**: значимые действия (активация/блок/роль/отзыв/ноды/инбаунды/удаление) — `AuditLog`
|
- **Аудит**: значимые действия (активация/блок/роль/отзыв/ноды/инбаунды/удаление) — `AuditLog`
|
||||||
(append-only, источник Web/Telegram/System).
|
(append-only, источник Web/Telegram/System).
|
||||||
|
- **Биллинг** (`AppRole.BillingEnabled`, недоступен для `admin`): пользователь оформляет
|
||||||
|
`PaymentRequest` на 3/6/12 мес (сумма — по `PricingSettings`, заморожена на заявке), админ
|
||||||
|
подтверждает/отклоняет на сайте или в Telegram (`pay:*`). Пока заявка `AwaitingConfirmation` —
|
||||||
|
конфиги не гасятся, даже если срок истёк (не по вине пользователя, что админ не успел). Просрочка
|
||||||
|
без заявки → `VpnConfig.Suspend()` (статус `Expired`, отдельно от `Disable()`/блокировки админом) —
|
||||||
|
см. [domain-model.md](docs/domain-model.md#billing--подписка-по-сроку).
|
||||||
- **Ротация конфига** (`Rotate()`) — новый UUID/ссылка, квоту не тратит. **Бот read-only** по конфигам.
|
- **Ротация конфига** (`Rotate()`) — новый UUID/ссылка, квоту не тратит. **Бот read-only** по конфигам.
|
||||||
- **Подписка**: агрегированная (`AppUser.SubscriptionToken`) + по конфигу. API без версионирования
|
- **Подписка**: агрегированная (`AppUser.SubscriptionToken`) + по конфигу. API без версионирования
|
||||||
(`/api`, без `v1`); подписка отдаёт `Subscription-Userinfo`.
|
(`/api`, без `v1`); подписка отдаёт `Subscription-Userinfo`.
|
||||||
@@ -168,9 +175,9 @@ docker compose up -d # api + postgres (+ web)
|
|||||||
## Ключевые решения
|
## Ключевые решения
|
||||||
|
|
||||||
См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — собственный
|
См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — собственный
|
||||||
диспетчер (не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; тарифы `Plan`
|
диспетчер (не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; биллинг
|
||||||
не реализованы; i18n — RU+EN (react-i18next); Telegram — long polling, только привязка (не signup);
|
опционален per-роль (недоступен для `admin`); i18n — RU+EN (react-i18next); Telegram — long polling,
|
||||||
история трафика — простая таблица + TTL; логирование — Serilog.
|
только привязка (не signup); история трафика — простая таблица + TTL; логирование — Serilog.
|
||||||
|
|
||||||
## Рабочие принципы
|
## Рабочие принципы
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Admin.Billing;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminBillingEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAdminBillingEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/billing")
|
||||||
|
.WithTags("Admin.Billing")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("/settings", GetSettings).Produces<BillingSettingsDto>();
|
||||||
|
admin.MapPut("/settings", UpdateSettings).Produces<BillingSettingsDto>();
|
||||||
|
admin
|
||||||
|
.MapGet("/requests", ListRequests)
|
||||||
|
.Produces<PagedList<AdminPaymentRequestDto>>();
|
||||||
|
admin
|
||||||
|
.MapPost("/requests/{id:guid}/confirm", ConfirmRequest)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPost("/requests/{id:guid}/reject", RejectRequest)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetSettings(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetBillingSettingsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateSettings(
|
||||||
|
UpdateBillingSettingsCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListRequests(
|
||||||
|
[AsParameters] ListPaymentRequestsRequest request,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = new ListPaymentRequestsQuery(request.Status, request.Page, request.PageSize);
|
||||||
|
var result = await sender.Send(query, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ConfirmRequest(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ConfirmPaymentRequestCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RejectRequest(
|
||||||
|
Guid id,
|
||||||
|
RejectPaymentRequestBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new RejectPaymentRequestCommand(id, body.Reason),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ListPaymentRequestsRequest(
|
||||||
|
PaymentRequestStatus? Status,
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record RejectPaymentRequestBody(string? Reason);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using PnvPanel.Api.Common;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Billing.CancelPaymentRequest;
|
||||||
|
using PnvPanel.Application.Billing.CreatePaymentRequest;
|
||||||
|
using PnvPanel.Application.Billing.GetMyBillingStatus;
|
||||||
|
using PnvPanel.Application.Billing.MarkPaymentSent;
|
||||||
|
using PnvPanel.Application.Billing.SendRequisitesToTelegram;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class BillingEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapBillingEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/billing").WithTags("Billing").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/status", GetStatus).Produces<BillingStatusDto>();
|
||||||
|
group.MapPost("/requests", CreateRequest).Produces<PaymentRequestDto>();
|
||||||
|
group
|
||||||
|
.MapPost("/requests/{id:guid}/cancel", CancelRequest)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group
|
||||||
|
.MapPost("/requests/{id:guid}/mark-paid", MarkPaid)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
group
|
||||||
|
.MapPost("/requests/{id:guid}/send-requisites", SendRequisites)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetMyBillingStatusQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateRequest(
|
||||||
|
CreatePaymentRequestBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new CreatePaymentRequestCommand(body.Period),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CancelRequest(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new CancelPaymentRequestCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> MarkPaid(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new MarkPaymentSentCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SendRequisites(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new SendRequisitesToTelegramCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreatePaymentRequestBody(PaymentPeriod Period);
|
||||||
@@ -53,7 +53,7 @@ public static class RoleEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit),
|
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit, body.BillingEnabled),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -84,6 +84,6 @@ public static class RoleEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit);
|
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit, bool BillingEnabled);
|
||||||
|
|
||||||
public sealed record ChangeUserRoleBody(Guid RoleId);
|
public sealed record ChangeUserRoleBody(Guid RoleId);
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ app.MapAdminAppEndpoints();
|
|||||||
app.MapAdminNewsEndpoints();
|
app.MapAdminNewsEndpoints();
|
||||||
app.MapAdminInstructionEndpoints();
|
app.MapAdminInstructionEndpoints();
|
||||||
app.MapAdminPricingEndpoints();
|
app.MapAdminPricingEndpoints();
|
||||||
|
app.MapBillingEndpoints();
|
||||||
|
app.MapAdminBillingEndpoints();
|
||||||
app.MapSupportEndpoints();
|
app.MapSupportEndpoints();
|
||||||
app.MapAdminSupportEndpoints();
|
app.MapAdminSupportEndpoints();
|
||||||
app.MapAdminMaintenanceEndpoints();
|
app.MapAdminMaintenanceEndpoints();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PnvPanel.Application.Admin.Activation;
|
using PnvPanel.Application.Admin.Activation;
|
||||||
|
using PnvPanel.Application.Admin.Billing;
|
||||||
using PnvPanel.Application.Admin.Support;
|
using PnvPanel.Application.Admin.Support;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
@@ -442,6 +443,55 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "pay":
|
||||||
|
{
|
||||||
|
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
|
{
|
||||||
|
await botClient.AnswerCallbackQuery(
|
||||||
|
callback.Id,
|
||||||
|
"Недостаточно прав.",
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result =
|
||||||
|
parts[1] == "approve"
|
||||||
|
? await sender.Send(
|
||||||
|
new ConfirmPaymentRequestCommand(requestId),
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
: await sender.Send(
|
||||||
|
new RejectPaymentRequestCommand(requestId, Reason: null),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
await botClient.AnswerCallbackQuery(
|
||||||
|
callback.Id,
|
||||||
|
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
if (callback.Message is not null)
|
||||||
|
{
|
||||||
|
var statusText = result.IsSuccess
|
||||||
|
? (
|
||||||
|
parts[1] == "approve"
|
||||||
|
? "✅ Оплата подтверждена."
|
||||||
|
: "❌ Оплата отклонена."
|
||||||
|
)
|
||||||
|
: $"⚠️ {result.Error.Message}";
|
||||||
|
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||||
|
await botClient.EditMessageText(
|
||||||
|
chatId.Value,
|
||||||
|
callback.Message.Id,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "cfg":
|
case "cfg":
|
||||||
{
|
{
|
||||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
using PnvPanel.Domain.Support;
|
using PnvPanel.Domain.Support;
|
||||||
using PnvPanel.Infrastructure.Telegram;
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -225,6 +226,56 @@ internal sealed class TelegramNotifier(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task NotifyAdminsPaymentRequestedAsync(
|
||||||
|
Guid requestId,
|
||||||
|
string userName,
|
||||||
|
PaymentPeriod period,
|
||||||
|
int amount,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var text =
|
||||||
|
$"💰 <b>{Escape(userName)}</b> заявляет об оплате за {PeriodLabel(period)} — {amount} ₽\nПроверьте поступление и подтвердите.";
|
||||||
|
|
||||||
|
var keyboard = new InlineKeyboardMarkup(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
InlineKeyboardButton.WithCallbackData("✅ Подтвердить", $"pay:approve:{requestId}"),
|
||||||
|
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"pay:reject:{requestId}"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
adminId,
|
||||||
|
text,
|
||||||
|
parseMode: ParseMode.Html,
|
||||||
|
replyMarkup: keyboard,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PeriodLabel(PaymentPeriod period) =>
|
||||||
|
period switch
|
||||||
|
{
|
||||||
|
PaymentPeriod.Quarter => "3 месяца",
|
||||||
|
PaymentPeriod.HalfYear => "полгода",
|
||||||
|
PaymentPeriod.Year => "год",
|
||||||
|
_ => period.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
public async Task NotifyUserAsync(
|
public async Task NotifyUserAsync(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
string message,
|
string message,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record AdminPaymentRequestDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid UserId,
|
||||||
|
string UserName,
|
||||||
|
PaymentPeriod Period,
|
||||||
|
int AmountSnapshot,
|
||||||
|
PaymentRequestStatus Status,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record BillingSettingsDto(
|
||||||
|
string RequisitesText,
|
||||||
|
int GraceDays,
|
||||||
|
bool DefaultBillingEnabledForNewRoles
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static BillingSettingsDto FromDomain(BillingSettings settings) =>
|
||||||
|
new(settings.RequisitesText, settings.GraceDays, settings.DefaultBillingEnabledForNewRoles);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record ConfirmPaymentRequestCommand(Guid RequestId) : ICommand<Result>;
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги,
|
||||||
|
/// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя —
|
||||||
|
/// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfirmPaymentRequestCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
ILogger<ConfirmPaymentRequestCommandHandler> logger
|
||||||
|
) : ICommandHandler<ConfirmPaymentRequestCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
ConfirmPaymentRequestCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } adminId)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||||
|
r => r.Id == command.RequestId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (request is null)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotFound);
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.Status
|
||||||
|
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
|
||||||
|
)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotDecidable);
|
||||||
|
|
||||||
|
var profile = await identityService.GetProfileAsync(request.UserId, cancellationToken);
|
||||||
|
if (profile is null)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
|
||||||
|
var newPaidUntil = baseline.AddMonths(request.Period.ToMonths());
|
||||||
|
|
||||||
|
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
|
||||||
|
request.UserId,
|
||||||
|
newPaidUntil,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (!extendResult.IsSuccess)
|
||||||
|
return extendResult;
|
||||||
|
|
||||||
|
request.Confirm(adminId);
|
||||||
|
|
||||||
|
var configs = await dbContext
|
||||||
|
.VpnConfigs.Where(c =>
|
||||||
|
c.UserId == request.UserId
|
||||||
|
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
||||||
|
)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var config in configs)
|
||||||
|
{
|
||||||
|
if (config.Status == ConfigStatus.Expired)
|
||||||
|
{
|
||||||
|
var inbound = await dbContext
|
||||||
|
.Inbounds.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||||
|
var node = inbound is null
|
||||||
|
? null
|
||||||
|
: await dbContext
|
||||||
|
.Nodes.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||||
|
|
||||||
|
if (inbound is not null && node is not null)
|
||||||
|
{
|
||||||
|
var updateResult = await gateway.UpdateClientAsync(
|
||||||
|
node,
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
config.ClientExternalId,
|
||||||
|
config.Protocol,
|
||||||
|
config.Label ?? config.ClientEmail,
|
||||||
|
enable: true,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updateResult.IsSuccess)
|
||||||
|
{
|
||||||
|
// Нода недоступна — не трогаем локальный статус, подхватится следующим
|
||||||
|
// подтверждением/циклом BillingService (идемпотентно).
|
||||||
|
logger.LogWarning(
|
||||||
|
"Failed to enable client for config {ConfigId} on node {NodeId} while confirming payment {RequestId}: {Error}",
|
||||||
|
config.Id,
|
||||||
|
node.Id,
|
||||||
|
request.Id,
|
||||||
|
updateResult.Error
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Resume();
|
||||||
|
await notifier.NotifyConfigStatusChangedAsync(
|
||||||
|
config.UserId,
|
||||||
|
config.Id,
|
||||||
|
config.Status,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.SetBillingExpiry(newPaidUntil);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"PaymentConfirmed",
|
||||||
|
"PaymentRequest",
|
||||||
|
request.Id.ToString(),
|
||||||
|
metadata: null,
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await telegramNotifier.NotifyUserAsync(
|
||||||
|
request.UserId,
|
||||||
|
$"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
|
||||||
|
null,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record GetBillingSettingsQuery : IQuery<Result<BillingSettingsDto>>;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class GetBillingSettingsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<GetBillingSettingsQuery, Result<BillingSettingsDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<BillingSettingsDto>> Handle(
|
||||||
|
GetBillingSettingsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext
|
||||||
|
.BillingSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Ещё не сохранялось ни разу — отдаём дефолты, а не ошибку (см. PricingSettings).
|
||||||
|
return Result.Success(
|
||||||
|
settings is null
|
||||||
|
? new BillingSettingsDto(string.Empty, BillingSettings.DefaultGraceDays, false)
|
||||||
|
: BillingSettingsDto.FromDomain(settings)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record ListPaymentRequestsQuery(PaymentRequestStatus? StatusFilter, int Page, int PageSize)
|
||||||
|
: IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class ListPaymentRequestsQueryHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService
|
||||||
|
) : IQueryHandler<ListPaymentRequestsQuery, Result<PagedList<AdminPaymentRequestDto>>>
|
||||||
|
{
|
||||||
|
public async Task<Result<PagedList<AdminPaymentRequestDto>>> Handle(
|
||||||
|
ListPaymentRequestsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var page = query.Page <= 0 ? 1 : query.Page;
|
||||||
|
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||||
|
|
||||||
|
var requestsQuery = dbContext.PaymentRequests.AsNoTracking();
|
||||||
|
if (query.StatusFilter is { } status)
|
||||||
|
requestsQuery = requestsQuery.Where(r => r.Status == status);
|
||||||
|
|
||||||
|
var page1 = await requestsQuery
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||||
|
|
||||||
|
var userNames = await identityService.GetUserNamesAsync(
|
||||||
|
page1.Items.Select(r => r.UserId).Distinct().ToList(),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
var items = page1
|
||||||
|
.Items.Select(r => new AdminPaymentRequestDto(
|
||||||
|
r.Id,
|
||||||
|
r.UserId,
|
||||||
|
userNames.GetValueOrDefault(r.UserId, "?"),
|
||||||
|
r.Period,
|
||||||
|
r.AmountSnapshot,
|
||||||
|
r.Status,
|
||||||
|
r.CreatedAt
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new PagedList<AdminPaymentRequestDto>(items, page1.Total, page1.Page, page1.PageSize)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record RejectPaymentRequestCommand(Guid RequestId, string? Reason) : ICommand<Result>;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class RejectPaymentRequestCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<RejectPaymentRequestCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
RejectPaymentRequestCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } adminId)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||||
|
r => r.Id == command.RequestId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (request is null)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotFound);
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.Status
|
||||||
|
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
|
||||||
|
)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotDecidable);
|
||||||
|
|
||||||
|
request.Reject(adminId, command.Reason);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
adminId,
|
||||||
|
"PaymentRejected",
|
||||||
|
"PaymentRequest",
|
||||||
|
request.Id.ToString(),
|
||||||
|
metadata: null,
|
||||||
|
AuditSource.Web
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var reasonSuffix = string.IsNullOrWhiteSpace(command.Reason)
|
||||||
|
? string.Empty
|
||||||
|
: $"\nПричина: {command.Reason}";
|
||||||
|
await telegramNotifier.NotifyUserAsync(
|
||||||
|
request.UserId,
|
||||||
|
$"❌ Заявка на оплату отклонена.{reasonSuffix}",
|
||||||
|
null,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed record UpdateBillingSettingsCommand(
|
||||||
|
string RequisitesText,
|
||||||
|
int GraceDays,
|
||||||
|
bool DefaultBillingEnabledForNewRoles
|
||||||
|
) : ICommand<Result<BillingSettingsDto>>;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class UpdateBillingSettingsCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<UpdateBillingSettingsCommand, Result<BillingSettingsDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<BillingSettingsDto>> Handle(
|
||||||
|
UpdateBillingSettingsCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext.BillingSettings.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
settings = BillingSettings.CreateDefault();
|
||||||
|
dbContext.BillingSettings.Add(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.Update(
|
||||||
|
command.RequisitesText,
|
||||||
|
command.GraceDays,
|
||||||
|
command.DefaultBillingEnabledForNewRoles
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success(BillingSettingsDto.FromDomain(settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
|
public sealed class UpdateBillingSettingsCommandValidator
|
||||||
|
: AbstractValidator<UpdateBillingSettingsCommand>
|
||||||
|
{
|
||||||
|
public UpdateBillingSettingsCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RequisitesText).NotEmpty().MaximumLength(4000);
|
||||||
|
RuleFor(x => x.GraceDays).GreaterThanOrEqualTo(0).LessThanOrEqualTo(365);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,9 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Roles;
|
namespace PnvPanel.Application.Admin.Roles;
|
||||||
|
|
||||||
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
|
public sealed record CreateRoleCommand(
|
||||||
: ICommand<Result<RoleDto>>;
|
string Name,
|
||||||
|
int MaxConfigs,
|
||||||
|
int MaxIpLimit,
|
||||||
|
bool BillingEnabled
|
||||||
|
) : ICommand<Result<RoleDto>>;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
|
|||||||
command.Name,
|
command.Name,
|
||||||
command.MaxConfigs,
|
command.MaxConfigs,
|
||||||
command.MaxIpLimit,
|
command.MaxIpLimit,
|
||||||
|
command.BillingEnabled,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,8 @@ public static class RoleErrors
|
|||||||
"Roles.CannotRemoveLastAdmin",
|
"Roles.CannotRemoveLastAdmin",
|
||||||
"Нельзя снять роль admin с последнего администратора."
|
"Нельзя снять роль admin с последнего администратора."
|
||||||
);
|
);
|
||||||
|
public static readonly Error BillingNotAllowedForAdmin = Error.Validation(
|
||||||
|
"Roles.BillingNotAllowedForAdmin",
|
||||||
|
"Биллинг нельзя включить для роли admin."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,9 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Roles;
|
namespace PnvPanel.Application.Admin.Roles;
|
||||||
|
|
||||||
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
|
public sealed record UpdateRoleCommand(
|
||||||
: ICommand<Result<RoleDto>>;
|
Guid RoleId,
|
||||||
|
int MaxConfigs,
|
||||||
|
int MaxIpLimit,
|
||||||
|
bool BillingEnabled
|
||||||
|
) : ICommand<Result<RoleDto>>;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
|
|||||||
command.RoleId,
|
command.RoleId,
|
||||||
command.MaxConfigs,
|
command.MaxConfigs,
|
||||||
command.MaxIpLimit,
|
command.MaxIpLimit,
|
||||||
|
command.BillingEnabled,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public sealed class ApproveRoleRequestCommandHandler(
|
|||||||
ticket.ProposedRoleName!,
|
ticket.ProposedRoleName!,
|
||||||
ticket.ProposedMaxConfigs!.Value,
|
ticket.ProposedMaxConfigs!.Value,
|
||||||
ticket.ProposedMaxIpLimit!.Value,
|
ticket.ProposedMaxIpLimit!.Value,
|
||||||
|
billingEnabled: false,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (!createResult.IsSuccess)
|
if (!createResult.IsSuccess)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing;
|
||||||
|
|
||||||
|
public static class BillingErrors
|
||||||
|
{
|
||||||
|
public static readonly Error NotEnabled = Error.Forbidden(
|
||||||
|
"Billing.NotEnabled",
|
||||||
|
"Биллинг не включён для вашей роли."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error PricingNotConfigured = Error.Failure(
|
||||||
|
"Billing.PricingNotConfigured",
|
||||||
|
"Цена для выбранного периода ещё не настроена админом."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error UnlimitedRoleNotSupported = Error.Failure(
|
||||||
|
"Billing.UnlimitedRoleNotSupported",
|
||||||
|
"Для роли без лимита конфигов сумма оплаты не может быть рассчитана."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error ActiveRequestExists = Error.Conflict(
|
||||||
|
"Billing.ActiveRequestExists",
|
||||||
|
"У вас уже есть активная заявка на оплату."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error RequestNotFound = Error.NotFound(
|
||||||
|
"Billing.RequestNotFound",
|
||||||
|
"Заявка на оплату не найдена."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error RequestNotCancellable = Error.Conflict(
|
||||||
|
"Billing.RequestNotCancellable",
|
||||||
|
"Заявку уже нельзя отменить."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error RequestNotAwaitingPayment = Error.Conflict(
|
||||||
|
"Billing.RequestNotAwaitingPayment",
|
||||||
|
"Заявка уже не ожидает оплаты."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error RequestNotDecidable = Error.Conflict(
|
||||||
|
"Billing.RequestNotDecidable",
|
||||||
|
"Заявка уже обработана."
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace PnvPanel.Application.Billing;
|
||||||
|
|
||||||
|
public sealed record BillingStatusDto(
|
||||||
|
bool BillingEnabled,
|
||||||
|
DateTimeOffset? PaidUntil,
|
||||||
|
bool Suspended,
|
||||||
|
string RequisitesText,
|
||||||
|
PaymentRequestDto? ActiveRequest
|
||||||
|
);
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.CancelPaymentRequest;
|
||||||
|
|
||||||
|
public sealed record CancelPaymentRequestCommand(Guid RequestId)
|
||||||
|
: ICommand<Result>,
|
||||||
|
IRequiresActivation;
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.CancelPaymentRequest;
|
||||||
|
|
||||||
|
public sealed class CancelPaymentRequestCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<CancelPaymentRequestCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
CancelPaymentRequestCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||||
|
r => r.Id == command.RequestId && r.UserId == userId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (request is null)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotFound);
|
||||||
|
|
||||||
|
if (request.Status != PaymentRequestStatus.AwaitingPayment)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotCancellable);
|
||||||
|
|
||||||
|
request.Cancel();
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
|
||||||
|
|
||||||
|
public sealed record CreatePaymentRequestCommand(PaymentPeriod Period)
|
||||||
|
: ICommand<Result<PaymentRequestDto>>,
|
||||||
|
IRequiresActivation;
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.CreatePaymentRequest;
|
||||||
|
|
||||||
|
public sealed class CreatePaymentRequestCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<CreatePaymentRequestCommand, Result<PaymentRequestDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<PaymentRequestDto>> Handle(
|
||||||
|
CreatePaymentRequestCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return Result.Failure<PaymentRequestDto>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||||
|
if (profile is null)
|
||||||
|
return Result.Failure<PaymentRequestDto>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
if (!profile.BillingEnabled)
|
||||||
|
return Result.Failure<PaymentRequestDto>(BillingErrors.NotEnabled);
|
||||||
|
|
||||||
|
if (profile.MaxConfigs == RoleQuota.Unlimited)
|
||||||
|
return Result.Failure<PaymentRequestDto>(BillingErrors.UnlimitedRoleNotSupported);
|
||||||
|
|
||||||
|
var hasActiveRequest = await dbContext.PaymentRequests.AnyAsync(
|
||||||
|
r =>
|
||||||
|
r.UserId == userId
|
||||||
|
&& (
|
||||||
|
r.Status == PaymentRequestStatus.AwaitingPayment
|
||||||
|
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (hasActiveRequest)
|
||||||
|
return Result.Failure<PaymentRequestDto>(BillingErrors.ActiveRequestExists);
|
||||||
|
|
||||||
|
var pricing = await dbContext.PricingSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
|
||||||
|
var ratePerMonth = command.Period switch
|
||||||
|
{
|
||||||
|
PaymentPeriod.Quarter => pricing?.PricePerConfigPerQuarter,
|
||||||
|
PaymentPeriod.HalfYear => pricing?.PricePerConfigPerHalfYear,
|
||||||
|
PaymentPeriod.Year => pricing?.PricePerConfigPerYear,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (ratePerMonth is not { } rate)
|
||||||
|
return Result.Failure<PaymentRequestDto>(BillingErrors.PricingNotConfigured);
|
||||||
|
|
||||||
|
var amount = rate * profile.MaxConfigs * command.Period.ToMonths();
|
||||||
|
|
||||||
|
var request = PaymentRequest.Create(userId, command.Period, amount);
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
|
||||||
|
return Result.Success(PaymentRequestDto.FromDomain(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.GetMyBillingStatus;
|
||||||
|
|
||||||
|
public sealed record GetMyBillingStatusQuery : IQuery<Result<BillingStatusDto>>, IRequiresActivation;
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.GetMyBillingStatus;
|
||||||
|
|
||||||
|
public sealed class GetMyBillingStatusQueryHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : IQueryHandler<GetMyBillingStatusQuery, Result<BillingStatusDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<BillingStatusDto>> Handle(
|
||||||
|
GetMyBillingStatusQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return Result.Failure<BillingStatusDto>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||||
|
if (profile is null)
|
||||||
|
return Result.Failure<BillingStatusDto>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
if (!profile.BillingEnabled)
|
||||||
|
return Result.Success(new BillingStatusDto(false, null, false, string.Empty, null));
|
||||||
|
|
||||||
|
var activeRequest = await dbContext
|
||||||
|
.PaymentRequests.AsNoTracking()
|
||||||
|
.Where(r =>
|
||||||
|
r.UserId == userId
|
||||||
|
&& (
|
||||||
|
r.Status == PaymentRequestStatus.AwaitingPayment
|
||||||
|
|| r.Status == PaymentRequestStatus.AwaitingConfirmation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var settings = await dbContext
|
||||||
|
.BillingSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new BillingStatusDto(
|
||||||
|
true,
|
||||||
|
profile.BillingPaidUntil,
|
||||||
|
profile.BillingSuspended,
|
||||||
|
settings?.RequisitesText ?? string.Empty,
|
||||||
|
activeRequest is null ? null : PaymentRequestDto.FromDomain(activeRequest)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.MarkPaymentSent;
|
||||||
|
|
||||||
|
public sealed record MarkPaymentSentCommand(Guid RequestId) : ICommand<Result>, IRequiresActivation;
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.MarkPaymentSent;
|
||||||
|
|
||||||
|
public sealed class MarkPaymentSentCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<MarkPaymentSentCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(MarkPaymentSentCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
|
||||||
|
r => r.Id == command.RequestId && r.UserId == userId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (request is null)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotFound);
|
||||||
|
|
||||||
|
if (request.Status != PaymentRequestStatus.AwaitingPayment)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotAwaitingPayment);
|
||||||
|
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
|
||||||
|
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||||
|
await telegramNotifier.NotifyAdminsPaymentRequestedAsync(
|
||||||
|
request.Id,
|
||||||
|
profile?.UserName ?? userId.ToString(),
|
||||||
|
request.Period,
|
||||||
|
request.AmountSnapshot,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing;
|
||||||
|
|
||||||
|
public sealed record PaymentRequestDto(
|
||||||
|
Guid Id,
|
||||||
|
PaymentPeriod Period,
|
||||||
|
int AmountSnapshot,
|
||||||
|
PaymentRequestStatus Status,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static PaymentRequestDto FromDomain(PaymentRequest request) =>
|
||||||
|
new(request.Id, request.Period, request.AmountSnapshot, request.Status, request.CreatedAt);
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.SendRequisitesToTelegram;
|
||||||
|
|
||||||
|
/// <summary>Дублирует реквизиты активной заявки в Telegram пользователю — для удобства (скопировать
|
||||||
|
/// с телефона), сам статус заявки не меняет.</summary>
|
||||||
|
public sealed record SendRequisitesToTelegramCommand(Guid RequestId)
|
||||||
|
: ICommand<Result>,
|
||||||
|
IRequiresActivation;
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Auth;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.Telegram;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Billing.SendRequisitesToTelegram;
|
||||||
|
|
||||||
|
public sealed class SendRequisitesToTelegramCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IIdentityService identityService,
|
||||||
|
ITelegramNotifier telegramNotifier,
|
||||||
|
ICurrentUser currentUser
|
||||||
|
) : ICommandHandler<SendRequisitesToTelegramCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
SendRequisitesToTelegramCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
var request = await dbContext
|
||||||
|
.PaymentRequests.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == command.RequestId && r.UserId == userId, cancellationToken);
|
||||||
|
if (request is null)
|
||||||
|
return Result.Failure(BillingErrors.RequestNotFound);
|
||||||
|
|
||||||
|
var linkInfo = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
|
||||||
|
if (!linkInfo.IsLinked)
|
||||||
|
return Result.Failure(TelegramErrors.NotLinked);
|
||||||
|
|
||||||
|
var settings = await dbContext
|
||||||
|
.BillingSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var text =
|
||||||
|
$"💳 Реквизиты для оплаты ({PeriodLabel(request.Period)}, {request.AmountSnapshot} ₽):\n{settings?.RequisitesText}";
|
||||||
|
|
||||||
|
await telegramNotifier.NotifyUserAsync(userId, text, null, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PeriodLabel(PaymentPeriod period) =>
|
||||||
|
period switch
|
||||||
|
{
|
||||||
|
PaymentPeriod.Quarter => "3 месяца",
|
||||||
|
PaymentPeriod.HalfYear => "полгода",
|
||||||
|
PaymentPeriod.Year => "год",
|
||||||
|
_ => period.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
|||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
using PnvPanel.Domain.Apps;
|
using PnvPanel.Domain.Apps;
|
||||||
using PnvPanel.Domain.Audit;
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
using PnvPanel.Domain.Configs;
|
using PnvPanel.Domain.Configs;
|
||||||
using PnvPanel.Domain.Inbounds;
|
using PnvPanel.Domain.Inbounds;
|
||||||
using PnvPanel.Domain.Instructions;
|
using PnvPanel.Domain.Instructions;
|
||||||
@@ -48,6 +49,10 @@ public interface IAppDbContext
|
|||||||
|
|
||||||
DbSet<PricingSettings> PricingSettings { get; }
|
DbSet<PricingSettings> PricingSettings { get; }
|
||||||
|
|
||||||
|
DbSet<BillingSettings> BillingSettings { get; }
|
||||||
|
|
||||||
|
DbSet<PaymentRequest> PaymentRequests { get; }
|
||||||
|
|
||||||
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
||||||
DatabaseFacade Database { get; }
|
DatabaseFacade Database { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,19 @@ public sealed record CurrentUserProfile(
|
|||||||
bool IsBlocked,
|
bool IsBlocked,
|
||||||
int MaxConfigs,
|
int MaxConfigs,
|
||||||
int MaxIpLimit,
|
int MaxIpLimit,
|
||||||
string SubscriptionToken
|
string SubscriptionToken,
|
||||||
|
bool BillingEnabled,
|
||||||
|
DateTimeOffset? BillingPaidUntil,
|
||||||
|
bool BillingSuspended
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Срез биллинг-полей пользователя — для фоновой джобы приостановки (см. BillingService),
|
||||||
|
/// не требует полного CurrentUserProfile (роль/квоты там не нужны).</summary>
|
||||||
|
public sealed record BillingUserDto(
|
||||||
|
Guid UserId,
|
||||||
|
DateTimeOffset? PaidUntil,
|
||||||
|
bool Suspended,
|
||||||
|
DateTimeOffset? LastWarnedForPaidUntil
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UserSummaryDto(
|
public sealed record UserSummaryDto(
|
||||||
@@ -135,4 +147,27 @@ public interface IIdentityService
|
|||||||
Guid exceptUserId,
|
Guid exceptUserId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>Пользователи с billing-ролью (role.BillingEnabled), не заблокированные — обход для
|
||||||
|
/// BillingService (приостановка за неуплату / предупреждения).</summary>
|
||||||
|
Task<IReadOnlyList<BillingUserDto>> ListBillingUsersAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Гасит конфиги за неуплату на уровне пользователя (BillingSuspended=true) — сами
|
||||||
|
/// конфиги гасит вызывающая сторона (см. BillingService, по образцу BlockUserCommandHandler).</summary>
|
||||||
|
Task<Result> SuspendBillingAsync(Guid userId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Продлевает оплаченный период, снимает приостановку и сбрасывает флаг "предупреждение
|
||||||
|
/// отправлено" (новый срок ещё не близко к истечению). Используется и при подтверждении оплаты,
|
||||||
|
/// и при первичной выдаче грейс-периода.</summary>
|
||||||
|
Task<Result> ExtendBillingPaidUntilAsync(
|
||||||
|
Guid userId,
|
||||||
|
DateTimeOffset paidUntil,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
Task<Result> MarkBillingWarningSentAsync(
|
||||||
|
Guid userId,
|
||||||
|
DateTimeOffset paidUntil,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Common.Interfaces;
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
|
|
||||||
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem);
|
public sealed record RoleDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
int MaxConfigs,
|
||||||
|
int MaxIpLimit,
|
||||||
|
bool IsSystem,
|
||||||
|
bool BillingEnabled
|
||||||
|
);
|
||||||
|
|
||||||
public interface IRoleService
|
public interface IRoleService
|
||||||
{
|
{
|
||||||
@@ -10,6 +17,7 @@ public interface IRoleService
|
|||||||
string name,
|
string name,
|
||||||
int maxConfigs,
|
int maxConfigs,
|
||||||
int maxIpLimit,
|
int maxIpLimit,
|
||||||
|
bool billingEnabled,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -17,6 +25,7 @@ public interface IRoleService
|
|||||||
Guid roleId,
|
Guid roleId,
|
||||||
int maxConfigs,
|
int maxConfigs,
|
||||||
int maxIpLimit,
|
int maxIpLimit,
|
||||||
|
bool billingEnabled,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using PnvPanel.Domain.Billing;
|
||||||
using PnvPanel.Domain.Support;
|
using PnvPanel.Domain.Support;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Common.Interfaces;
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
@@ -51,4 +52,14 @@ public interface ITelegramNotifier
|
|||||||
TicketType type,
|
TicketType type,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>Пользователь нажал «Я оплатил» — инлайн-кнопки «Подтвердить/Отклонить», решается
|
||||||
|
/// полностью в Telegram (аналогично заявке на роль).</summary>
|
||||||
|
Task NotifyAdminsPaymentRequestedAsync(
|
||||||
|
Guid requestId,
|
||||||
|
string userName,
|
||||||
|
PaymentPeriod period,
|
||||||
|
int amount,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,9 @@ public static class ConfigErrors
|
|||||||
"Configs.NodeUnavailable",
|
"Configs.NodeUnavailable",
|
||||||
"Сервер временно недоступен. Попробуйте повторить операцию позже."
|
"Сервер временно недоступен. Попробуйте повторить операцию позже."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static readonly Error BillingRequired = Error.Forbidden(
|
||||||
|
"Configs.BillingRequired",
|
||||||
|
"Требуется оплата подписки — оформите заявку на оплату в разделе «Оплата»."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ public sealed class CreateVpnConfigCommandHandler(
|
|||||||
if (!inbound.AllowedRoleIds.Contains(profile.RoleId))
|
if (!inbound.AllowedRoleIds.Contains(profile.RoleId))
|
||||||
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAllowedForRole);
|
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAllowedForRole);
|
||||||
|
|
||||||
|
// Не даём обойти приостановку за неуплату созданием нового конфига — см. BillingService.
|
||||||
|
if (
|
||||||
|
profile.BillingEnabled
|
||||||
|
&& (profile.BillingPaidUntil is null || profile.BillingPaidUntil < DateTimeOffset.UtcNow)
|
||||||
|
)
|
||||||
|
return Result.Failure<VpnConfigDto>(ConfigErrors.BillingRequired);
|
||||||
|
|
||||||
var node = await dbContext
|
var node = await dbContext
|
||||||
.Nodes.AsNoTracking()
|
.Nodes.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||||
@@ -47,6 +54,8 @@ public sealed class CreateVpnConfigCommandHandler(
|
|||||||
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
|
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
|
||||||
|
|
||||||
var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label);
|
var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label);
|
||||||
|
if (profile.BillingEnabled)
|
||||||
|
config.SetBillingExpiry(profile.BillingPaidUntil);
|
||||||
|
|
||||||
var reserveResult = await ReserveQuotaSlotAsync(
|
var reserveResult = await ReserveQuotaSlotAsync(
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using PnvPanel.Domain.Common;
|
||||||
|
|
||||||
|
namespace PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Единственная строка в таблице — глобальные настройки биллинга, редактируются админом.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BillingSettings : Entity
|
||||||
|
{
|
||||||
|
/// <summary>Реквизиты для оплаты (карта/крипто-адрес/СБП и т.д.) — произвольный текст.</summary>
|
||||||
|
public string RequisitesText { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Дней грейс-периода для пользователя, впервые попавшего под биллинг (назначена
|
||||||
|
/// billing-роль, или роли включили BillingEnabled, а PaidUntil ещё ни разу не выставлялся).</summary>
|
||||||
|
public int GraceDays { get; private set; } = DefaultGraceDays;
|
||||||
|
|
||||||
|
public const int DefaultGraceDays = 7;
|
||||||
|
|
||||||
|
/// <summary>Начальное состояние чекбокса "Включить биллинг" в диалоге создания новой роли —
|
||||||
|
/// чистое удобство админа, не влияет на уже существующие роли и не гейтит ничего само по себе.</summary>
|
||||||
|
public bool DefaultBillingEnabledForNewRoles { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAt { get; private set; }
|
||||||
|
|
||||||
|
private BillingSettings() { }
|
||||||
|
|
||||||
|
public static BillingSettings CreateDefault()
|
||||||
|
{
|
||||||
|
return new BillingSettings
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
GraceDays = DefaultGraceDays,
|
||||||
|
DefaultBillingEnabledForNewRoles = false,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(string requisitesText, int graceDays, bool defaultBillingEnabledForNewRoles)
|
||||||
|
{
|
||||||
|
RequisitesText = requisitesText;
|
||||||
|
GraceDays = graceDays;
|
||||||
|
DefaultBillingEnabledForNewRoles = defaultBillingEnabledForNewRoles;
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
public enum PaymentPeriod
|
||||||
|
{
|
||||||
|
Quarter,
|
||||||
|
HalfYear,
|
||||||
|
Year,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PaymentPeriodExtensions
|
||||||
|
{
|
||||||
|
public static int ToMonths(this PaymentPeriod period) =>
|
||||||
|
period switch
|
||||||
|
{
|
||||||
|
PaymentPeriod.Quarter => 3,
|
||||||
|
PaymentPeriod.HalfYear => 6,
|
||||||
|
PaymentPeriod.Year => 12,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(period)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using PnvPanel.Domain.Common;
|
||||||
|
using PnvPanel.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Заявка пользователя на оплату подписки за период (квартал/полгода/год). Сумма замораживается на
|
||||||
|
/// момент создания (по действовавшей на тот момент ставке PricingSettings) — последующее изменение
|
||||||
|
/// прайса админом не меняет уже созданные заявки. Не более одной активной (AwaitingPayment/
|
||||||
|
/// AwaitingConfirmation) заявки на пользователя — инвариант проверяется на уровне Application.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PaymentRequest : Entity
|
||||||
|
{
|
||||||
|
public Guid UserId { get; private set; }
|
||||||
|
public PaymentPeriod Period { get; private set; }
|
||||||
|
public int AmountSnapshot { get; private set; }
|
||||||
|
public PaymentRequestStatus Status { get; private set; }
|
||||||
|
public Guid? DecidedBy { get; private set; }
|
||||||
|
public DateTimeOffset? DecidedAt { get; private set; }
|
||||||
|
public string? RejectionReason { get; private set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
|
private PaymentRequest() { }
|
||||||
|
|
||||||
|
public static PaymentRequest Create(Guid userId, PaymentPeriod period, int amountSnapshot)
|
||||||
|
{
|
||||||
|
return new PaymentRequest
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
Period = period,
|
||||||
|
AmountSnapshot = amountSnapshot,
|
||||||
|
Status = PaymentRequestStatus.AwaitingPayment,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Пользователь нажал «Я оплатил» — уходит на подтверждение админом.</summary>
|
||||||
|
public void MarkPaymentSent()
|
||||||
|
{
|
||||||
|
if (Status != PaymentRequestStatus.AwaitingPayment)
|
||||||
|
throw new DomainException($"Нельзя отметить оплаченной заявку в статусе {Status}.");
|
||||||
|
|
||||||
|
Status = PaymentRequestStatus.AwaitingConfirmation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Пользователь передумал — можно только пока не заявлено «Я оплатил» (после этого
|
||||||
|
/// решение уже за админом, отменять заявку из-под него нельзя).</summary>
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
if (Status != PaymentRequestStatus.AwaitingPayment)
|
||||||
|
throw new DomainException($"Нельзя отменить заявку в статусе {Status}.");
|
||||||
|
|
||||||
|
Status = PaymentRequestStatus.Cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Допустимо и из AwaitingPayment (админ увидел оплату раньше, чем юзер нажал кнопку),
|
||||||
|
/// и из AwaitingConfirmation (обычный путь).</summary>
|
||||||
|
public void Confirm(Guid decidedBy)
|
||||||
|
{
|
||||||
|
EnsureDecidable();
|
||||||
|
Status = PaymentRequestStatus.Confirmed;
|
||||||
|
DecidedBy = decidedBy;
|
||||||
|
DecidedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reject(Guid decidedBy, string? reason)
|
||||||
|
{
|
||||||
|
EnsureDecidable();
|
||||||
|
Status = PaymentRequestStatus.Rejected;
|
||||||
|
DecidedBy = decidedBy;
|
||||||
|
DecidedAt = DateTimeOffset.UtcNow;
|
||||||
|
RejectionReason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureDecidable()
|
||||||
|
{
|
||||||
|
if (Status is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation))
|
||||||
|
throw new DomainException($"Заявка уже обработана (статус {Status}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
public enum PaymentRequestStatus
|
||||||
|
{
|
||||||
|
AwaitingPayment,
|
||||||
|
AwaitingConfirmation,
|
||||||
|
Confirmed,
|
||||||
|
Rejected,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
@@ -88,6 +88,26 @@ public sealed class VpnConfig : Entity
|
|||||||
Status = ConfigStatus.Active;
|
Status = ConfigStatus.Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Приостановка за неуплату (биллинг) — отдельный статус от Disable/Enable (блокировка
|
||||||
|
/// админом), чтобы разблокировка/оплата не задевали друг друга по ошибке.</summary>
|
||||||
|
public void Suspend()
|
||||||
|
{
|
||||||
|
if (Status == ConfigStatus.Active)
|
||||||
|
Status = ConfigStatus.Expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Возврат из приостановки за неуплату — только то, что было погашено именно ею.</summary>
|
||||||
|
public void Resume()
|
||||||
|
{
|
||||||
|
if (Status == ConfigStatus.Expired)
|
||||||
|
Status = ConfigStatus.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Денормализация даты окончания оплаченного периода пользователя (см. AppUser.BillingPaidUntil)
|
||||||
|
/// на конфиг — используется в Subscription-Userinfo для VPN-клиента (см. SubscriptionAssembler).
|
||||||
|
/// Null для ролей без биллинга.</summary>
|
||||||
|
public void SetBillingExpiry(DateTimeOffset? expiresAt) => ExpiresAt = expiresAt;
|
||||||
|
|
||||||
private void EnsureActive(string action)
|
private void EnsureActive(string action)
|
||||||
{
|
{
|
||||||
if (Status != ConfigStatus.Active)
|
if (Status != ConfigStatus.Active)
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.BackgroundJobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Обходит пользователей с billing-ролью: гасит конфиги, у кого истёк оплаченный период (Active →
|
||||||
|
/// Expired, отдельно от блокировки админом — см. VpnConfig.Suspend), и шлёт предупреждение за 3 дня
|
||||||
|
/// до истечения. Пользователь с заявкой на оплату в AwaitingConfirmation не трогается вообще — пока
|
||||||
|
/// админ не подтвердит/отклонит, приостановка не наступает (не по вине пользователя, что админ не
|
||||||
|
/// успел проверить оплату).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BillingService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<BillingService> logger
|
||||||
|
) : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Interval = TimeSpan.FromHours(1);
|
||||||
|
private static readonly TimeSpan WarningWindow = TimeSpan.FromDays(3);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(Interval);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Billing cycle failed");
|
||||||
|
}
|
||||||
|
} while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var identityService = scope.ServiceProvider.GetRequiredService<IIdentityService>();
|
||||||
|
var gateway = scope.ServiceProvider.GetRequiredService<IXuiPanelGateway>();
|
||||||
|
var notifier = scope.ServiceProvider.GetRequiredService<IRealtimeNotifier>();
|
||||||
|
var telegramNotifier = scope.ServiceProvider.GetRequiredService<ITelegramNotifier>();
|
||||||
|
|
||||||
|
var billingUsers = await identityService.ListBillingUsersAsync(cancellationToken);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
foreach (var user in billingUsers)
|
||||||
|
{
|
||||||
|
// Заявка ждёт решения админа — не гасим конфиги, пока он не подтвердит/отклонит (см.
|
||||||
|
// ConfirmPaymentRequestCommandHandler/RejectPaymentRequestCommandHandler).
|
||||||
|
var hasAwaitingConfirmation = await dbContext.PaymentRequests.AnyAsync(
|
||||||
|
r => r.UserId == user.UserId && r.Status == PaymentRequestStatus.AwaitingConfirmation,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (hasAwaitingConfirmation)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (user.PaidUntil is not { } paidUntil || paidUntil <= now)
|
||||||
|
{
|
||||||
|
// Идемпотентно гасим конфиги на каждом тике (самовосстановление после временной
|
||||||
|
// недоступности ноды), но уведомление/аудит/флаг — только один раз, при первом обнаружении.
|
||||||
|
await DisableActiveConfigsAsync(user.UserId, dbContext, gateway, notifier, cancellationToken);
|
||||||
|
|
||||||
|
if (!user.Suspended)
|
||||||
|
{
|
||||||
|
await identityService.SuspendBillingAsync(user.UserId, cancellationToken);
|
||||||
|
|
||||||
|
dbContext.AuditLogs.Add(
|
||||||
|
AuditLog.Create(
|
||||||
|
actorId: null,
|
||||||
|
"BillingSuspended",
|
||||||
|
"User",
|
||||||
|
user.UserId.ToString(),
|
||||||
|
metadata: null,
|
||||||
|
AuditSource.System
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await telegramNotifier.NotifyUserAsync(
|
||||||
|
user.UserId,
|
||||||
|
"⛔ Оплата подписки истекла — конфиги приостановлены. Продлите в разделе «Оплата», чтобы восстановить доступ.",
|
||||||
|
"/billing",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.Suspended)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (paidUntil - now <= WarningWindow && user.LastWarnedForPaidUntil != paidUntil)
|
||||||
|
{
|
||||||
|
await telegramNotifier.NotifyUserAsync(
|
||||||
|
user.UserId,
|
||||||
|
$"⚠️ Оплата подписки истекает {paidUntil:dd.MM.yyyy}. Продлите в разделе «Оплата», иначе конфиги будут приостановлены.",
|
||||||
|
"/billing",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
await identityService.MarkBillingWarningSentAsync(user.UserId, paidUntil, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisableActiveConfigsAsync(
|
||||||
|
Guid userId,
|
||||||
|
AppDbContext dbContext,
|
||||||
|
IXuiPanelGateway gateway,
|
||||||
|
IRealtimeNotifier notifier,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var configs = await dbContext
|
||||||
|
.VpnConfigs.Where(c => c.UserId == userId && c.Status == ConfigStatus.Active)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var config in configs)
|
||||||
|
{
|
||||||
|
var inbound = await dbContext
|
||||||
|
.Inbounds.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
|
||||||
|
var node = inbound is null
|
||||||
|
? null
|
||||||
|
: await dbContext
|
||||||
|
.Nodes.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
|
||||||
|
|
||||||
|
if (inbound is not null && node is not null)
|
||||||
|
{
|
||||||
|
var updateResult = await gateway.UpdateClientAsync(
|
||||||
|
node,
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
config.ClientExternalId,
|
||||||
|
config.Protocol,
|
||||||
|
config.Label ?? config.ClientEmail,
|
||||||
|
enable: false,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updateResult.IsSuccess)
|
||||||
|
{
|
||||||
|
// Нода недоступна — конфиг остаётся Active, подхватится следующим циклом.
|
||||||
|
logger.LogWarning(
|
||||||
|
"Failed to disable client for config {ConfigId} on node {NodeId} while suspending user {UserId} for non-payment: {Error}",
|
||||||
|
config.Id,
|
||||||
|
node.Id,
|
||||||
|
userId,
|
||||||
|
updateResult.Error
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Suspend();
|
||||||
|
await notifier.NotifyConfigStatusChangedAsync(
|
||||||
|
config.UserId,
|
||||||
|
config.Id,
|
||||||
|
config.Status,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,7 @@ public static class DependencyInjection
|
|||||||
services.AddHostedService<TrafficSyncService>();
|
services.AddHostedService<TrafficSyncService>();
|
||||||
services.AddHostedService<NodeHealthCheckService>();
|
services.AddHostedService<NodeHealthCheckService>();
|
||||||
services.AddHostedService<TrafficRetentionService>();
|
services.AddHostedService<TrafficRetentionService>();
|
||||||
|
services.AddHostedService<BillingService>();
|
||||||
|
|
||||||
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
|
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public class AppRole : IdentityRole<Guid>
|
|||||||
|
|
||||||
public bool IsSystem { get; set; }
|
public bool IsSystem { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Включает биллинг для пользователей с этой ролью (недоступно для роли admin — см.
|
||||||
|
/// RoleService.UpdateRoleAsync).</summary>
|
||||||
|
public bool BillingEnabled { get; set; }
|
||||||
|
|
||||||
public AppRole() { }
|
public AppRole() { }
|
||||||
|
|
||||||
public AppRole(string name)
|
public AppRole(string name)
|
||||||
|
|||||||
@@ -24,4 +24,15 @@ public class AppUser : IdentityUser<Guid>
|
|||||||
public string? TelegramUsername { get; set; }
|
public string? TelegramUsername { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset? TelegramLinkedAt { get; set; }
|
public DateTimeOffset? TelegramLinkedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Оплачено до этой даты (только для billing-ролей); null — ещё ни разу не выставлялся.
|
||||||
|
/// Продлевается подтверждённой PaymentRequest, см. ConfirmPaymentRequestCommandHandler.</summary>
|
||||||
|
public DateTimeOffset? BillingPaidUntil { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Конфиги приостановлены за неуплату (см. BillingService). Отдельно от IsBlocked.</summary>
|
||||||
|
public bool BillingSuspended { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Для какого BillingPaidUntil уже отправлено предупреждение «истекает через N дней» —
|
||||||
|
/// не даёт слать его повторно на каждый тик джобы, пока PaidUntil не изменится.</summary>
|
||||||
|
public DateTimeOffset? BillingLastWarnedForPaidUntil { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ internal sealed class IdentityService(
|
|||||||
user.IsBlocked,
|
user.IsBlocked,
|
||||||
role.MaxConfigs,
|
role.MaxConfigs,
|
||||||
role.MaxIpLimit,
|
role.MaxIpLimit,
|
||||||
user.SubscriptionToken
|
user.SubscriptionToken,
|
||||||
|
role.BillingEnabled,
|
||||||
|
user.BillingPaidUntil,
|
||||||
|
user.BillingSuspended
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +379,80 @@ internal sealed class IdentityService(
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<BillingUserDto>> ListBillingUsersAsync(
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var billingRoleNames = await roleManager
|
||||||
|
.Roles.AsNoTracking()
|
||||||
|
.Where(r => r.BillingEnabled)
|
||||||
|
.Select(r => r.Name!)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (billingRoleNames.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var users = new List<BillingUserDto>();
|
||||||
|
foreach (var roleName in billingRoleNames)
|
||||||
|
{
|
||||||
|
var usersInRole = await userManager.GetUsersInRoleAsync(roleName);
|
||||||
|
users.AddRange(
|
||||||
|
usersInRole
|
||||||
|
.Where(u => !u.IsBlocked)
|
||||||
|
.Select(u => new BillingUserDto(
|
||||||
|
u.Id,
|
||||||
|
u.BillingPaidUntil,
|
||||||
|
u.BillingSuspended,
|
||||||
|
u.BillingLastWarnedForPaidUntil
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> SuspendBillingAsync(Guid userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||||
|
if (user is null)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
user.BillingSuspended = true;
|
||||||
|
await userManager.UpdateAsync(user);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> ExtendBillingPaidUntilAsync(
|
||||||
|
Guid userId,
|
||||||
|
DateTimeOffset paidUntil,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||||
|
if (user is null)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
user.BillingPaidUntil = paidUntil;
|
||||||
|
user.BillingSuspended = false;
|
||||||
|
user.BillingLastWarnedForPaidUntil = null;
|
||||||
|
await userManager.UpdateAsync(user);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> MarkBillingWarningSentAsync(
|
||||||
|
Guid userId,
|
||||||
|
DateTimeOffset paidUntil,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||||
|
if (user is null)
|
||||||
|
return Result.Failure(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
|
user.BillingLastWarnedForPaidUntil = paidUntil;
|
||||||
|
await userManager.UpdateAsync(user);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
|
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
|
||||||
{
|
{
|
||||||
var roles = await userManager.GetRolesAsync(user);
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
|||||||
@@ -4,29 +4,36 @@ using PnvPanel.Application.Admin.Roles;
|
|||||||
using PnvPanel.Application.Admin.Users;
|
using PnvPanel.Application.Admin.Users;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
namespace PnvPanel.Infrastructure.Identity;
|
namespace PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
internal sealed class RoleService(
|
internal sealed class RoleService(
|
||||||
RoleManager<AppRole> roleManager,
|
RoleManager<AppRole> roleManager,
|
||||||
UserManager<AppUser> userManager
|
UserManager<AppUser> userManager,
|
||||||
|
IAppDbContext dbContext
|
||||||
) : IRoleService
|
) : IRoleService
|
||||||
{
|
{
|
||||||
public async Task<Result<RoleDto>> CreateRoleAsync(
|
public async Task<Result<RoleDto>> CreateRoleAsync(
|
||||||
string name,
|
string name,
|
||||||
int maxConfigs,
|
int maxConfigs,
|
||||||
int maxIpLimit,
|
int maxIpLimit,
|
||||||
|
bool billingEnabled,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (await roleManager.RoleExistsAsync(name))
|
if (await roleManager.RoleExistsAsync(name))
|
||||||
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
|
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
|
||||||
|
|
||||||
|
if (billingEnabled && name.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Result.Failure<RoleDto>(RoleErrors.BillingNotAllowedForAdmin);
|
||||||
|
|
||||||
var role = new AppRole(name)
|
var role = new AppRole(name)
|
||||||
{
|
{
|
||||||
MaxConfigs = maxConfigs,
|
MaxConfigs = maxConfigs,
|
||||||
MaxIpLimit = maxIpLimit,
|
MaxIpLimit = maxIpLimit,
|
||||||
IsSystem = false,
|
IsSystem = false,
|
||||||
|
BillingEnabled = billingEnabled,
|
||||||
};
|
};
|
||||||
var result = await roleManager.CreateAsync(role);
|
var result = await roleManager.CreateAsync(role);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
@@ -46,6 +53,7 @@ internal sealed class RoleService(
|
|||||||
Guid roleId,
|
Guid roleId,
|
||||||
int maxConfigs,
|
int maxConfigs,
|
||||||
int maxIpLimit,
|
int maxIpLimit,
|
||||||
|
bool billingEnabled,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -53,13 +61,50 @@ internal sealed class RoleService(
|
|||||||
if (role is null)
|
if (role is null)
|
||||||
return Result.Failure<RoleDto>(RoleErrors.NotFound);
|
return Result.Failure<RoleDto>(RoleErrors.NotFound);
|
||||||
|
|
||||||
|
if (
|
||||||
|
billingEnabled
|
||||||
|
&& role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
return Result.Failure<RoleDto>(RoleErrors.BillingNotAllowedForAdmin);
|
||||||
|
|
||||||
|
var billingJustEnabled = billingEnabled && !role.BillingEnabled;
|
||||||
|
|
||||||
role.MaxConfigs = maxConfigs;
|
role.MaxConfigs = maxConfigs;
|
||||||
role.MaxIpLimit = maxIpLimit;
|
role.MaxIpLimit = maxIpLimit;
|
||||||
|
role.BillingEnabled = billingEnabled;
|
||||||
await roleManager.UpdateAsync(role);
|
await roleManager.UpdateAsync(role);
|
||||||
|
|
||||||
|
if (billingJustEnabled)
|
||||||
|
{
|
||||||
|
var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!);
|
||||||
|
await InitializeBillingGraceAsync(usersInRole, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.Success(ToDto(role));
|
return Result.Success(ToDto(role));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Первая выдача грейс-периода — только пользователям, у которых оплата ещё ни разу не
|
||||||
|
/// выставлялась (BillingPaidUntil == null), чтобы не сбрасывать уже приостановленным/оплатившим.</summary>
|
||||||
|
private async Task InitializeBillingGraceAsync(
|
||||||
|
IEnumerable<AppUser> users,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var usersNeedingGrace = users.Where(u => u.BillingPaidUntil is null).ToList();
|
||||||
|
if (usersNeedingGrace.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var settings = await dbContext.BillingSettings.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
var graceDays = settings?.GraceDays ?? BillingSettings.DefaultGraceDays;
|
||||||
|
var paidUntil = DateTimeOffset.UtcNow.AddDays(graceDays);
|
||||||
|
|
||||||
|
foreach (var user in usersNeedingGrace)
|
||||||
|
{
|
||||||
|
user.BillingPaidUntil = paidUntil;
|
||||||
|
await userManager.UpdateAsync(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken)
|
public async Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var role = await roleManager.FindByIdAsync(roleId.ToString());
|
var role = await roleManager.FindByIdAsync(roleId.ToString());
|
||||||
@@ -81,7 +126,14 @@ internal sealed class RoleService(
|
|||||||
{
|
{
|
||||||
return await roleManager
|
return await roleManager
|
||||||
.Roles.OrderBy(r => r.Name)
|
.Roles.OrderBy(r => r.Name)
|
||||||
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem))
|
.Select(r => new RoleDto(
|
||||||
|
r.Id,
|
||||||
|
r.Name!,
|
||||||
|
r.MaxConfigs,
|
||||||
|
r.MaxIpLimit,
|
||||||
|
r.IsSystem,
|
||||||
|
r.BillingEnabled
|
||||||
|
))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +166,13 @@ internal sealed class RoleService(
|
|||||||
await userManager.RemoveFromRolesAsync(user, currentRoles);
|
await userManager.RemoveFromRolesAsync(user, currentRoles);
|
||||||
|
|
||||||
await userManager.AddToRoleAsync(user, role.Name!);
|
await userManager.AddToRoleAsync(user, role.Name!);
|
||||||
|
|
||||||
|
if (role.BillingEnabled)
|
||||||
|
await InitializeBillingGraceAsync([user], cancellationToken);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RoleDto ToDto(AppRole role) =>
|
private static RoleDto ToDto(AppRole role) =>
|
||||||
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem);
|
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem, role.BillingEnabled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using PnvPanel.Application.Common.Interfaces;
|
|||||||
using PnvPanel.Domain.Activation;
|
using PnvPanel.Domain.Activation;
|
||||||
using PnvPanel.Domain.Apps;
|
using PnvPanel.Domain.Apps;
|
||||||
using PnvPanel.Domain.Audit;
|
using PnvPanel.Domain.Audit;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
using PnvPanel.Domain.Configs;
|
using PnvPanel.Domain.Configs;
|
||||||
using PnvPanel.Domain.Inbounds;
|
using PnvPanel.Domain.Inbounds;
|
||||||
using PnvPanel.Domain.Instructions;
|
using PnvPanel.Domain.Instructions;
|
||||||
@@ -58,6 +59,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
|
|
||||||
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
|
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
|
||||||
|
|
||||||
|
public DbSet<BillingSettings> BillingSettings => Set<BillingSettings>();
|
||||||
|
|
||||||
|
public DbSet<PaymentRequest> PaymentRequests => Set<PaymentRequest>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class BillingSettingsConfiguration : IEntityTypeConfiguration<BillingSettings>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BillingSettings> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("BillingSettings");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
builder.Property(x => x.RequisitesText).IsRequired().HasMaxLength(4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public class PaymentRequestConfiguration : IEntityTypeConfiguration<PaymentRequest>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PaymentRequest> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("PaymentRequests");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
builder.Property(x => x.Period).HasConversion<string>().HasMaxLength(32);
|
||||||
|
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
|
||||||
|
builder.Property(x => x.RejectionReason).HasMaxLength(500);
|
||||||
|
|
||||||
|
builder.HasIndex(x => new { x.UserId, x.Status });
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1040
File diff suppressed because it is too large
Load Diff
+105
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBilling : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "BillingLastWarnedForPaidUntil",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "BillingPaidUntil",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "BillingSuspended",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "BillingEnabled",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "BillingSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
RequisitesText = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
|
||||||
|
GraceDays = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_BillingSettings", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PaymentRequests",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Period = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
AmountSnapshot = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
DecidedBy = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
DecidedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
RejectionReason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PaymentRequests", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PaymentRequests_UserId_Status",
|
||||||
|
table: "PaymentRequests",
|
||||||
|
columns: new[] { "UserId", "Status" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "BillingSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PaymentRequests");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BillingLastWarnedForPaidUntil",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BillingPaidUntil",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BillingSuspended",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BillingEnabled",
|
||||||
|
table: "AspNetRoles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1043
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBillingDefaultForNewRoles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "DefaultBillingEnabledForNewRoles",
|
||||||
|
table: "BillingSettings",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultBillingEnabledForNewRoles",
|
||||||
|
table: "BillingSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -250,6 +250,73 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("AuditLogs", (string)null);
|
b.ToTable("AuditLogs", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("DefaultBillingEnabledForNewRoles")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("GraceDays")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("RequisitesText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("character varying(4000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("BillingSettings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AmountSnapshot")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DecidedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DecidedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Period")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("RejectionReason")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Status");
|
||||||
|
|
||||||
|
b.ToTable("PaymentRequests", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -713,6 +780,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("BillingEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
b.Property<string>("ConcurrencyStamp")
|
||||||
.IsConcurrencyToken()
|
.IsConcurrencyToken()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -758,6 +828,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<Guid?>("ActivatedBy")
|
b.Property<Guid?>("ActivatedBy")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("BillingLastWarnedForPaidUntil")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("BillingPaidUntil")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("BillingSuspended")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
b.Property<string>("ConcurrencyStamp")
|
||||||
.IsConcurrencyToken()
|
.IsConcurrencyToken()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
|
using PnvPanel.Application.Admin.Billing;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Configs;
|
||||||
|
using PnvPanel.Domain.Inbounds;
|
||||||
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Admin.Billing;
|
||||||
|
|
||||||
|
public class ConfirmPaymentRequestCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
|
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||||
|
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||||
|
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||||
|
private readonly ILogger<ConfirmPaymentRequestCommandHandler> _logger = Substitute.For<
|
||||||
|
ILogger<ConfirmPaymentRequestCommandHandler>
|
||||||
|
>();
|
||||||
|
|
||||||
|
private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) =>
|
||||||
|
new(
|
||||||
|
userId,
|
||||||
|
"alice",
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"premium",
|
||||||
|
IsActivated: true,
|
||||||
|
IsBlocked: false,
|
||||||
|
MaxConfigs: 5,
|
||||||
|
MaxIpLimit: 3,
|
||||||
|
SubscriptionToken: "sub-token",
|
||||||
|
BillingEnabled: true,
|
||||||
|
BillingPaidUntil: paidUntil,
|
||||||
|
BillingSuspended: paidUntil is null
|
||||||
|
);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenNoPriorPayment_ExtendsFromNow()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, paidUntil: null));
|
||||||
|
_identityService
|
||||||
|
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Result.Success());
|
||||||
|
|
||||||
|
var handler = new ConfirmPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
_gateway,
|
||||||
|
_notifier,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(adminId, "admin"),
|
||||||
|
_logger
|
||||||
|
);
|
||||||
|
|
||||||
|
var before = DateTimeOffset.UtcNow;
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new ConfirmPaymentRequestCommand(request.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
|
||||||
|
await _identityService
|
||||||
|
.Received(1)
|
||||||
|
.ExtendBillingPaidUntilAsync(
|
||||||
|
userId,
|
||||||
|
Arg.Is<DateTimeOffset>(d => d >= before.AddMonths(3).AddMinutes(-1)),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenPaidUntilInFuture_ExtendsFromExistingPaidUntil()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var existingPaidUntil = DateTimeOffset.UtcNow.AddDays(20);
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, paidUntil: existingPaidUntil));
|
||||||
|
_identityService
|
||||||
|
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Result.Success());
|
||||||
|
|
||||||
|
var handler = new ConfirmPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
_gateway,
|
||||||
|
_notifier,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(adminId, "admin"),
|
||||||
|
_logger
|
||||||
|
);
|
||||||
|
|
||||||
|
await handler.Handle(new ConfirmPaymentRequestCommand(request.Id), CancellationToken.None);
|
||||||
|
|
||||||
|
var expected = existingPaidUntil.AddMonths(3);
|
||||||
|
await _identityService
|
||||||
|
.Received(1)
|
||||||
|
.ExtendBillingPaidUntilAsync(
|
||||||
|
userId,
|
||||||
|
Arg.Is<DateTimeOffset>(d => Math.Abs((d - expected).TotalSeconds) < 5),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ResumesExpiredConfigsAndSyncsExpiry()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var node = Node.Register(
|
||||||
|
"node-1",
|
||||||
|
new Uri("https://node1.example.com"),
|
||||||
|
new NodeCredentials("admin", "protected"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||||
|
var expiredConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||||
|
expiredConfig.AssignRemoteClient("ext-1");
|
||||||
|
expiredConfig.Suspend();
|
||||||
|
var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null);
|
||||||
|
activeConfig.AssignRemoteClient("ext-2");
|
||||||
|
|
||||||
|
dbContext.Nodes.Add(node);
|
||||||
|
dbContext.Inbounds.Add(inbound);
|
||||||
|
dbContext.VpnConfigs.AddRange(expiredConfig, activeConfig);
|
||||||
|
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, paidUntil: null));
|
||||||
|
_identityService
|
||||||
|
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Result.Success());
|
||||||
|
_gateway
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<VpnProtocol>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
enable: true,
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
)
|
||||||
|
.Returns(Result.Success());
|
||||||
|
|
||||||
|
var handler = new ConfirmPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
_gateway,
|
||||||
|
_notifier,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(adminId, "admin"),
|
||||||
|
_logger
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new ConfirmPaymentRequestCommand(request.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(ConfigStatus.Active, expiredConfig.Status);
|
||||||
|
Assert.NotNull(expiredConfig.ExpiresAt);
|
||||||
|
Assert.NotNull(activeConfig.ExpiresAt);
|
||||||
|
Assert.Equal(expiredConfig.ExpiresAt, activeConfig.ExpiresAt);
|
||||||
|
await _gateway
|
||||||
|
.Received(1)
|
||||||
|
.UpdateClientAsync(
|
||||||
|
Arg.Any<Node>(),
|
||||||
|
inbound.RemoteInboundId,
|
||||||
|
"ext-1",
|
||||||
|
VpnProtocol.Vless,
|
||||||
|
Arg.Any<string>(),
|
||||||
|
enable: true,
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenRequestAlreadyDecided_ReturnsNotDecidable()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
request.Cancel();
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new ConfirmPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
_gateway,
|
||||||
|
_notifier,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(adminId, "admin"),
|
||||||
|
_logger
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new ConfirmPaymentRequestCommand(request.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.RequestNotDecidable, result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using PnvPanel.Application.Admin.Billing;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Admin.Billing;
|
||||||
|
|
||||||
|
public class RejectPaymentRequestCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenAwaitingConfirmation_RejectsAndNotifiesUser()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new RejectPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(adminId, "admin")
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new RejectPaymentRequestCommand(request.Id, "Платёж не найден"),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
|
||||||
|
Assert.Equal("Платёж не найден", request.RejectionReason);
|
||||||
|
await _telegramNotifier
|
||||||
|
.Received(1)
|
||||||
|
.NotifyUserAsync(
|
||||||
|
userId,
|
||||||
|
Arg.Is<string>(m => m.Contains("Платёж не найден")),
|
||||||
|
Arg.Any<string?>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenRequestNotFound_ReturnsRequestNotFound()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
|
||||||
|
var handler = new RejectPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new RejectPaymentRequestCommand(Guid.NewGuid(), null),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -72,8 +72,8 @@ public class FactoryResetCommandHandlerTests
|
|||||||
.Returns(
|
.Returns(
|
||||||
new List<RoleDto>
|
new List<RoleDto>
|
||||||
{
|
{
|
||||||
new(adminRoleId, "admin", -1, -1, IsSystem: true),
|
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
|
||||||
new(customRoleId, "premium", 10, 5, IsSystem: false),
|
new(customRoleId, "premium", 10, 5, IsSystem: false, BillingEnabled: false),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
_roleService
|
_roleService
|
||||||
|
|||||||
+4
-3
@@ -27,8 +27,8 @@ public class ApproveRoleRequestCommandHandlerTests
|
|||||||
|
|
||||||
var newRoleId = Guid.NewGuid();
|
var newRoleId = Guid.NewGuid();
|
||||||
_roleService
|
_roleService
|
||||||
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>())
|
.CreateRoleAsync("premium", 10, 5, false, Arg.Any<CancellationToken>())
|
||||||
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false)));
|
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false, false)));
|
||||||
_roleService
|
_roleService
|
||||||
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>())
|
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>())
|
||||||
.Returns(Result.Success());
|
.Returns(Result.Success());
|
||||||
@@ -51,7 +51,7 @@ public class ApproveRoleRequestCommandHandlerTests
|
|||||||
Assert.Equal(TicketStatus.Resolved, ticket.Status);
|
Assert.Equal(TicketStatus.Resolved, ticket.Status);
|
||||||
await _roleService
|
await _roleService
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>());
|
.CreateRoleAsync("premium", 10, 5, false, Arg.Any<CancellationToken>());
|
||||||
await _roleService
|
await _roleService
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
|
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
|
||||||
@@ -100,6 +100,7 @@ public class ApproveRoleRequestCommandHandlerTests
|
|||||||
Arg.Any<string>(),
|
Arg.Any<string>(),
|
||||||
Arg.Any<int>(),
|
Arg.Any<int>(),
|
||||||
Arg.Any<int>(),
|
Arg.Any<int>(),
|
||||||
|
Arg.Any<bool>(),
|
||||||
Arg.Any<CancellationToken>()
|
Arg.Any<CancellationToken>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ public class GetCurrentUserQueryHandlerTests
|
|||||||
false,
|
false,
|
||||||
3,
|
3,
|
||||||
RoleQuota.Unlimited,
|
RoleQuota.Unlimited,
|
||||||
"sub-token"
|
"sub-token",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
_identityService
|
_identityService
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ public class LoginCommandHandlerTests
|
|||||||
IsBlocked: false,
|
IsBlocked: false,
|
||||||
MaxConfigs: 3,
|
MaxConfigs: 3,
|
||||||
MaxIpLimit: RoleQuota.Unlimited,
|
MaxIpLimit: RoleQuota.Unlimited,
|
||||||
SubscriptionToken: "sub-token"
|
SubscriptionToken: "sub-token",
|
||||||
|
BillingEnabled: false,
|
||||||
|
BillingPaidUntil: null,
|
||||||
|
BillingSuspended: false
|
||||||
);
|
);
|
||||||
|
|
||||||
_identityService
|
_identityService
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ public class RefreshCommandHandlerTests
|
|||||||
IsBlocked: false,
|
IsBlocked: false,
|
||||||
MaxConfigs: 3,
|
MaxConfigs: 3,
|
||||||
MaxIpLimit: RoleQuota.Unlimited,
|
MaxIpLimit: RoleQuota.Unlimited,
|
||||||
SubscriptionToken: "sub-token"
|
SubscriptionToken: "sub-token",
|
||||||
|
BillingEnabled: false,
|
||||||
|
BillingPaidUntil: null,
|
||||||
|
BillingSuspended: false
|
||||||
);
|
);
|
||||||
var rotated = new RotatedRefreshToken(
|
var rotated = new RotatedRefreshToken(
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Billing.CancelPaymentRequest;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Billing.CancelPaymentRequest;
|
||||||
|
|
||||||
|
public class CancelPaymentRequestCommandHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenAwaitingPayment_Cancels()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000);
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new CancelPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CancelPaymentRequestCommand(request.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(PaymentRequestStatus.Cancelled, request.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenAwaitingConfirmation_ReturnsNotCancellable()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new CancelPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CancelPaymentRequestCommand(request.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.RequestNotCancellable, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenOwnedByAnotherUser_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1000);
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new CancelPaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
FakeCurrentUser.Authenticated(Guid.NewGuid())
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CancelPaymentRequestCommand(request.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.RequestNotFound, result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Billing.CreatePaymentRequest;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Pricing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Billing.CreatePaymentRequest;
|
||||||
|
|
||||||
|
public class CreatePaymentRequestCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
|
|
||||||
|
private static CurrentUserProfile Profile(
|
||||||
|
Guid userId,
|
||||||
|
bool billingEnabled = true,
|
||||||
|
int maxConfigs = 5,
|
||||||
|
DateTimeOffset? paidUntil = null
|
||||||
|
) =>
|
||||||
|
new(
|
||||||
|
userId,
|
||||||
|
"alice",
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"premium",
|
||||||
|
IsActivated: true,
|
||||||
|
IsBlocked: false,
|
||||||
|
MaxConfigs: maxConfigs,
|
||||||
|
MaxIpLimit: 3,
|
||||||
|
SubscriptionToken: "sub-token",
|
||||||
|
BillingEnabled: billingEnabled,
|
||||||
|
BillingPaidUntil: paidUntil,
|
||||||
|
BillingSuspended: false
|
||||||
|
);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithConfiguredPricing_ComputesAmountAndCreatesRequest()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var pricing = PricingSettings.CreateDefault();
|
||||||
|
pricing.Update(500, 450, 400);
|
||||||
|
dbContext.PricingSettings.Add(pricing);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, maxConfigs: 5));
|
||||||
|
|
||||||
|
var handler = new CreatePaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(500 * 5 * 3, result.Value.AmountSnapshot);
|
||||||
|
Assert.Equal(PaymentRequestStatus.AwaitingPayment, result.Value.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, billingEnabled: false));
|
||||||
|
|
||||||
|
var handler = new CreatePaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.NotEnabled, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenRoleHasUnlimitedConfigs_ReturnsUnlimitedRoleNotSupported()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, maxConfigs: RoleQuota.Unlimited));
|
||||||
|
|
||||||
|
var handler = new CreatePaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.UnlimitedRoleNotSupported, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenActiveRequestExists_ReturnsActiveRequestExists()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
dbContext.PaymentRequests.Add(PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1000));
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId));
|
||||||
|
|
||||||
|
var handler = new CreatePaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.ActiveRequestExists, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenPricingNotConfigured_ReturnsPricingNotConfigured()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId));
|
||||||
|
|
||||||
|
var handler = new CreatePaymentRequestCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new CreatePaymentRequestCommand(PaymentPeriod.Quarter),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.PricingNotConfigured, result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using PnvPanel.Application.Billing.GetMyBillingStatus;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Billing.GetMyBillingStatus;
|
||||||
|
|
||||||
|
public class GetMyBillingStatusQueryHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
|
|
||||||
|
private static CurrentUserProfile Profile(
|
||||||
|
Guid userId,
|
||||||
|
bool billingEnabled,
|
||||||
|
DateTimeOffset? paidUntil = null,
|
||||||
|
bool suspended = false
|
||||||
|
) =>
|
||||||
|
new(
|
||||||
|
userId,
|
||||||
|
"alice",
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"premium",
|
||||||
|
IsActivated: true,
|
||||||
|
IsBlocked: false,
|
||||||
|
MaxConfigs: 5,
|
||||||
|
MaxIpLimit: 3,
|
||||||
|
SubscriptionToken: "sub-token",
|
||||||
|
BillingEnabled: billingEnabled,
|
||||||
|
BillingPaidUntil: paidUntil,
|
||||||
|
BillingSuspended: suspended
|
||||||
|
);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenBillingNotEnabled_ReturnsDisabledStatusWithoutQueryingRequests()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, billingEnabled: false));
|
||||||
|
|
||||||
|
var handler = new GetMyBillingStatusQueryHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new GetMyBillingStatusQuery(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(result.Value.BillingEnabled);
|
||||||
|
Assert.Null(result.Value.ActiveRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenActiveRequestExists_IncludesItInStatus()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var paidUntil = DateTimeOffset.UtcNow.AddDays(10);
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Quarter, 1500);
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
_identityService
|
||||||
|
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Profile(userId, billingEnabled: true, paidUntil: paidUntil));
|
||||||
|
|
||||||
|
var handler = new GetMyBillingStatusQueryHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(new GetMyBillingStatusQuery(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Value.BillingEnabled);
|
||||||
|
Assert.Equal(paidUntil, result.Value.PaidUntil);
|
||||||
|
Assert.NotNull(result.Value.ActiveRequest);
|
||||||
|
Assert.Equal(request.Id, result.Value.ActiveRequest!.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using PnvPanel.Application.Billing;
|
||||||
|
using PnvPanel.Application.Billing.MarkPaymentSent;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Tests.TestSupport;
|
||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Tests.Billing.MarkPaymentSent;
|
||||||
|
|
||||||
|
public class MarkPaymentSentCommandHandlerTests
|
||||||
|
{
|
||||||
|
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||||
|
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenAwaitingPayment_MovesToAwaitingConfirmationAndNotifiesAdmins()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Year, 6000);
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new MarkPaymentSentCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(userId, "alice")
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new MarkPaymentSentCommand(request.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
|
||||||
|
await _telegramNotifier
|
||||||
|
.Received(1)
|
||||||
|
.NotifyAdminsPaymentRequestedAsync(
|
||||||
|
request.Id,
|
||||||
|
Arg.Any<string>(),
|
||||||
|
PaymentPeriod.Year,
|
||||||
|
6000,
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WhenAlreadyAwaitingConfirmation_ReturnsNotAwaitingPayment()
|
||||||
|
{
|
||||||
|
using var dbContext = InMemoryDbContextFactory.Create();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(userId, PaymentPeriod.Year, 6000);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
dbContext.PaymentRequests.Add(request);
|
||||||
|
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var handler = new MarkPaymentSentCommandHandler(
|
||||||
|
dbContext,
|
||||||
|
_identityService,
|
||||||
|
_telegramNotifier,
|
||||||
|
FakeCurrentUser.Authenticated(userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await handler.Handle(
|
||||||
|
new MarkPaymentSentCommand(request.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(BillingErrors.RequestNotAwaitingPayment, result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -28,7 +28,10 @@ public class RequireActivationBehaviorTests
|
|||||||
IsBlocked: false,
|
IsBlocked: false,
|
||||||
MaxConfigs: 3,
|
MaxConfigs: 3,
|
||||||
MaxIpLimit: RoleQuota.Unlimited,
|
MaxIpLimit: RoleQuota.Unlimited,
|
||||||
SubscriptionToken: "sub-token"
|
SubscriptionToken: "sub-token",
|
||||||
|
BillingEnabled: false,
|
||||||
|
BillingPaidUntil: null,
|
||||||
|
BillingSuspended: false
|
||||||
);
|
);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+4
-1
@@ -47,7 +47,10 @@ public class GetMyConfigsQueryHandlerTests
|
|||||||
false,
|
false,
|
||||||
5,
|
5,
|
||||||
RoleQuota.Unlimited,
|
RoleQuota.Unlimited,
|
||||||
"sub-token"
|
"sub-token",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -27,7 +27,10 @@ public class RotateVpnConfigCommandHandlerTests
|
|||||||
false,
|
false,
|
||||||
3,
|
3,
|
||||||
RoleQuota.Unlimited,
|
RoleQuota.Unlimited,
|
||||||
"sub-token"
|
"sub-token",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+4
-1
@@ -25,7 +25,10 @@ public class AddTicketCommentCommandHandlerTests
|
|||||||
IsBlocked: false,
|
IsBlocked: false,
|
||||||
MaxConfigs: 3,
|
MaxConfigs: 3,
|
||||||
MaxIpLimit: RoleQuota.Unlimited,
|
MaxIpLimit: RoleQuota.Unlimited,
|
||||||
SubscriptionToken: "token"
|
SubscriptionToken: "token",
|
||||||
|
BillingEnabled: false,
|
||||||
|
BillingPaidUntil: null,
|
||||||
|
BillingSuspended: false
|
||||||
);
|
);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ public class CreateRoleRequestTicketCommandHandlerTests
|
|||||||
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
|
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
|
||||||
_roleService
|
_roleService
|
||||||
.ListRolesAsync(Arg.Any<CancellationToken>())
|
.ListRolesAsync(Arg.Any<CancellationToken>())
|
||||||
.Returns(new List<RoleDto> { new(roleId, "premium", 5, 2, false) });
|
.Returns(new List<RoleDto> { new(roleId, "premium", 5, 2, false, false) });
|
||||||
|
|
||||||
var handler = new CreateRoleRequestTicketCommandHandler(
|
var handler = new CreateRoleRequestTicketCommandHandler(
|
||||||
dbContext,
|
dbContext,
|
||||||
@@ -61,7 +61,7 @@ public class CreateRoleRequestTicketCommandHandlerTests
|
|||||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
|
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
|
||||||
_roleService
|
_roleService
|
||||||
.ListRolesAsync(Arg.Any<CancellationToken>())
|
.ListRolesAsync(Arg.Any<CancellationToken>())
|
||||||
.Returns(new List<RoleDto> { new(roleId, "admin", -1, -1, true) });
|
.Returns(new List<RoleDto> { new(roleId, "admin", -1, -1, true, false) });
|
||||||
|
|
||||||
var handler = new CreateRoleRequestTicketCommandHandler(
|
var handler = new CreateRoleRequestTicketCommandHandler(
|
||||||
dbContext,
|
dbContext,
|
||||||
|
|||||||
+9
-6
@@ -21,7 +21,10 @@ public class ListSelectableRolesQueryHandlerTests
|
|||||||
IsBlocked: false,
|
IsBlocked: false,
|
||||||
MaxConfigs: 3,
|
MaxConfigs: 3,
|
||||||
MaxIpLimit: 1,
|
MaxIpLimit: 1,
|
||||||
SubscriptionToken: "token"
|
SubscriptionToken: "token",
|
||||||
|
BillingEnabled: false,
|
||||||
|
BillingPaidUntil: null,
|
||||||
|
BillingSuspended: false
|
||||||
);
|
);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -37,9 +40,9 @@ public class ListSelectableRolesQueryHandlerTests
|
|||||||
.Returns(
|
.Returns(
|
||||||
new List<RoleDto>
|
new List<RoleDto>
|
||||||
{
|
{
|
||||||
new(adminRoleId, "admin", -1, -1, IsSystem: true),
|
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
|
||||||
new(currentRoleId, "user", 3, 1, IsSystem: true),
|
new(currentRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false),
|
||||||
new(extendedRoleId, "extended", 10, 5, IsSystem: false),
|
new(extendedRoleId, "extended", 10, 5, IsSystem: false, BillingEnabled: false),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
_identityService
|
_identityService
|
||||||
@@ -71,8 +74,8 @@ public class ListSelectableRolesQueryHandlerTests
|
|||||||
.Returns(
|
.Returns(
|
||||||
new List<RoleDto>
|
new List<RoleDto>
|
||||||
{
|
{
|
||||||
new(adminRoleId, "admin", -1, -1, IsSystem: true),
|
new(adminRoleId, "admin", -1, -1, IsSystem: true, BillingEnabled: false),
|
||||||
new(userRoleId, "user", 3, 1, IsSystem: true),
|
new(userRoleId, "user", 3, 1, IsSystem: true, BillingEnabled: false),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
_identityService
|
_identityService
|
||||||
|
|||||||
+4
-1
@@ -98,7 +98,10 @@ public class GetLoginRequestStatusQueryHandlerTests
|
|||||||
false,
|
false,
|
||||||
3,
|
3,
|
||||||
RoleQuota.Unlimited,
|
RoleQuota.Unlimited,
|
||||||
"sub-token"
|
"sub-token",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(profile);
|
||||||
_jwtTokenService
|
_jwtTokenService
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using PnvPanel.Domain.Billing;
|
||||||
|
using PnvPanel.Domain.Exceptions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PnvPanel.Domain.Tests.Billing;
|
||||||
|
|
||||||
|
public class PaymentRequestTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Create_StartsInAwaitingPayment()
|
||||||
|
{
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
|
||||||
|
|
||||||
|
Assert.Equal(PaymentRequestStatus.AwaitingPayment, request.Status);
|
||||||
|
Assert.Equal(1500, request.AmountSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkPaymentSent_FromAwaitingPayment_MovesToAwaitingConfirmation()
|
||||||
|
{
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
|
||||||
|
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
|
||||||
|
Assert.Equal(PaymentRequestStatus.AwaitingConfirmation, request.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MarkPaymentSent_WhenAlreadySent_Throws()
|
||||||
|
{
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
|
||||||
|
Assert.Throws<DomainException>(() => request.MarkPaymentSent());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cancel_FromAwaitingPayment_MovesToCancelled()
|
||||||
|
{
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
|
||||||
|
|
||||||
|
request.Cancel();
|
||||||
|
|
||||||
|
Assert.Equal(PaymentRequestStatus.Cancelled, request.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cancel_AfterMarkPaymentSent_Throws()
|
||||||
|
{
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
|
||||||
|
Assert.Throws<DomainException>(() => request.Cancel());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Confirm_FromAwaitingConfirmation_Succeeds()
|
||||||
|
{
|
||||||
|
var adminId = Guid.NewGuid();
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Year, 5000);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
|
||||||
|
request.Confirm(adminId);
|
||||||
|
|
||||||
|
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
|
||||||
|
Assert.Equal(adminId, request.DecidedBy);
|
||||||
|
Assert.NotNull(request.DecidedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Confirm_FromAwaitingPayment_Succeeds()
|
||||||
|
{
|
||||||
|
// Админ мог увидеть оплату раньше, чем пользователь нажал "Я оплатил".
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Year, 5000);
|
||||||
|
|
||||||
|
request.Confirm(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.Equal(PaymentRequestStatus.Confirmed, request.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reject_FromAwaitingConfirmation_SetsReason()
|
||||||
|
{
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.HalfYear, 3000);
|
||||||
|
request.MarkPaymentSent();
|
||||||
|
|
||||||
|
request.Reject(Guid.NewGuid(), "Платёж не найден");
|
||||||
|
|
||||||
|
Assert.Equal(PaymentRequestStatus.Rejected, request.Status);
|
||||||
|
Assert.Equal("Платёж не найден", request.RejectionReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Confirm_WhenAlreadyDecided_Throws()
|
||||||
|
{
|
||||||
|
var request = PaymentRequest.Create(Guid.NewGuid(), PaymentPeriod.Quarter, 1500);
|
||||||
|
request.Cancel();
|
||||||
|
|
||||||
|
Assert.Throws<DomainException>(() => request.Confirm(Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,62 @@ public class VpnConfigTests
|
|||||||
Assert.Equal(ConfigStatus.Revoked, config.Status);
|
Assert.Equal(ConfigStatus.Revoked, config.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Suspend_WhenActive_SetsExpired()
|
||||||
|
{
|
||||||
|
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||||
|
|
||||||
|
config.Suspend();
|
||||||
|
|
||||||
|
Assert.Equal(ConfigStatus.Expired, config.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Suspend_WhenDisabledByAdmin_DoesNotOverrideBlock()
|
||||||
|
{
|
||||||
|
// Suspend (биллинг) не должен путать своё состояние с Disable (блокировка админом) — иначе
|
||||||
|
// Resume() ошибочно вернёт в Active конфиг, погашенный не за неуплату.
|
||||||
|
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||||
|
config.Disable();
|
||||||
|
|
||||||
|
config.Suspend();
|
||||||
|
|
||||||
|
Assert.Equal(ConfigStatus.Disabled, config.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resume_WhenExpired_ReturnsToActive()
|
||||||
|
{
|
||||||
|
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||||
|
config.Suspend();
|
||||||
|
|
||||||
|
config.Resume();
|
||||||
|
|
||||||
|
Assert.Equal(ConfigStatus.Active, config.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resume_WhenDisabledByAdmin_DoesNotResurrect()
|
||||||
|
{
|
||||||
|
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||||
|
config.Disable();
|
||||||
|
|
||||||
|
config.Resume();
|
||||||
|
|
||||||
|
Assert.Equal(ConfigStatus.Disabled, config.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetBillingExpiry_SetsExpiresAt()
|
||||||
|
{
|
||||||
|
var config = VpnConfig.Create(Guid.NewGuid(), Guid.NewGuid(), VpnProtocol.Vless, null);
|
||||||
|
var expiresAt = DateTimeOffset.UtcNow.AddMonths(3);
|
||||||
|
|
||||||
|
config.SetBillingExpiry(expiresAt);
|
||||||
|
|
||||||
|
Assert.Equal(expiresAt, config.ExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateTraffic_SetsBytesAndLastSyncAt()
|
public void UpdateTraffic_SetsBytesAndLastSyncAt()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using PnvPanel.IntegrationTests.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
|
||||||
|
|
||||||
|
namespace PnvPanel.IntegrationTests.Billing;
|
||||||
|
|
||||||
|
[Collection(IntegrationTestCollection.Name)]
|
||||||
|
public class BillingFlowTests(PnvPanelWebApplicationFactory factory)
|
||||||
|
{
|
||||||
|
private sealed record RoleResponse(Guid Id, string Name, bool BillingEnabled);
|
||||||
|
|
||||||
|
private sealed record ActivationRequestResponse(Guid Id);
|
||||||
|
|
||||||
|
private sealed record BillingStatusResponse(
|
||||||
|
bool BillingEnabled,
|
||||||
|
DateTimeOffset? PaidUntil,
|
||||||
|
bool Suspended,
|
||||||
|
string RequisitesText,
|
||||||
|
object? ActiveRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверяет сквозной путь, недоступный unit-тестам (RoleService — Infrastructure/Identity):
|
||||||
|
/// назначение billing-роли автоматически выдаёт грейс-период, и он виден пользователю через
|
||||||
|
/// /api/billing/status.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AssigningBillingRole_GrantsGracePeriod_VisibleInBillingStatus()
|
||||||
|
{
|
||||||
|
using var adminClient = factory.CreateClient();
|
||||||
|
var adminToken = await LoginAsAdminAsync(adminClient);
|
||||||
|
adminClient.UseBearerToken(adminToken);
|
||||||
|
|
||||||
|
var createRoleResponse = await adminClient.PostJsonAsync(
|
||||||
|
"/api/admin/roles",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
name = $"billing_{Guid.NewGuid():N}"[..20],
|
||||||
|
maxConfigs = 5,
|
||||||
|
maxIpLimit = -1,
|
||||||
|
billingEnabled = true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, createRoleResponse.StatusCode);
|
||||||
|
var role = await createRoleResponse.ReadAsAsync<RoleResponse>();
|
||||||
|
Assert.True(role!.BillingEnabled);
|
||||||
|
|
||||||
|
using var userClient = factory.CreateClient();
|
||||||
|
var userName = $"bill_{Guid.NewGuid():N}"[..20];
|
||||||
|
var (userId, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
|
||||||
|
userClient.UseBearerToken(userToken);
|
||||||
|
|
||||||
|
var activationRequestResponse = await userClient.PostJsonAsync(
|
||||||
|
"/api/activation/request",
|
||||||
|
new { comment = (string?)null }
|
||||||
|
);
|
||||||
|
var activationRequest =
|
||||||
|
await activationRequestResponse.ReadAsAsync<ActivationRequestResponse>();
|
||||||
|
var approveActivationResponse = await adminClient.PostAsync(
|
||||||
|
$"/api/admin/activation-requests/{activationRequest!.Id}/approve",
|
||||||
|
content: null
|
||||||
|
);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, approveActivationResponse.StatusCode);
|
||||||
|
|
||||||
|
var assignRoleResponse = await adminClient.PatchAsJsonAsync(
|
||||||
|
$"/api/admin/users/{userId}/role",
|
||||||
|
new { roleId = role.Id },
|
||||||
|
PnvPanel.IntegrationTests.TestSupport.HttpClientJsonExtensions.JsonOptions
|
||||||
|
);
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, assignRoleResponse.StatusCode);
|
||||||
|
|
||||||
|
var beforeCheck = DateTimeOffset.UtcNow;
|
||||||
|
var statusResponse = await userClient.GetAsync("/api/billing/status");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||||
|
var status = await statusResponse.ReadAsAsync<BillingStatusResponse>();
|
||||||
|
|
||||||
|
Assert.True(status!.BillingEnabled);
|
||||||
|
Assert.False(status.Suspended);
|
||||||
|
Assert.NotNull(status.PaidUntil);
|
||||||
|
// Дефолтный грейс — 7 дней (BillingSettings.DefaultGraceDays), пока админ не настроил своё.
|
||||||
|
Assert.True(status.PaidUntil > beforeCheck.AddDays(6));
|
||||||
|
Assert.True(status.PaidUntil < beforeCheck.AddDays(8));
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-4
@@ -147,6 +147,19 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
|
|||||||
| GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
|
| GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
|
||||||
| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
|
| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
|
||||||
|
|
||||||
|
## Billing (пользователь)
|
||||||
|
|
||||||
|
Группа `/api/billing`, `RequireAuthorization()` + `IRequiresActivation`. Доступна независимо от роли —
|
||||||
|
`GET /status` сам сообщает `billingEnabled=false`, если биллинг для роли пользователя не включён.
|
||||||
|
|
||||||
|
| Метод | Путь | Тело запроса | Тело ответа |
|
||||||
|
| ----- | -------------------------------------------- | ---------------------- | ------------- |
|
||||||
|
| GET | `/api/billing/status` | — | `BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto \| null }` |
|
||||||
|
| POST | `/api/billing/requests` | `{ period }` (`Quarter`/`HalfYear`/`Year`) | `PaymentRequestDto` (`409 Billing.ActiveRequestExists`, если уже есть активная заявка; `409 Billing.UnlimitedRoleNotSupported` для ролей с `MaxConfigs=-1`; `500 Billing.PricingNotConfigured`, если ставка для периода не задана) |
|
||||||
|
| POST | `/api/billing/requests/{id}/cancel` | — | `204 No Content` (только из `AwaitingPayment`) |
|
||||||
|
| POST | `/api/billing/requests/{id}/mark-paid` | — | `204 No Content` (`AwaitingPayment → AwaitingConfirmation`, уведомляет админов в Telegram) |
|
||||||
|
| POST | `/api/billing/requests/{id}/send-requisites` | — | `204 No Content` (дублирует реквизиты в свой Telegram; `409 Telegram.NotLinked`, если Telegram не привязан) |
|
||||||
|
|
||||||
## Support (пользователь)
|
## Support (пользователь)
|
||||||
|
|
||||||
Группа `/api/support`, `RequireAuthorization()` + `IRequiresActivation` (кроме `GET /attachments/{id}`,
|
Группа `/api/support`, `RequireAuthorization()` + `IRequiresActivation` (кроме `GET /attachments/{id}`,
|
||||||
@@ -245,8 +258,8 @@ reject/approve владением тикета не ограничены. Еди
|
|||||||
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
||||||
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
||||||
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` |
|
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (`400`, если `billingEnabled=true` для `name="admin"`) |
|
||||||
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` |
|
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (то же ограничение на `admin`; включение `billingEnabled` ретроактивно выдаёт грейс-период уже назначенным пользователям без `PaidUntil`) |
|
||||||
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
| 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`, новая роль другая, и это единственный админ) |
|
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) |
|
||||||
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) |
|
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) |
|
||||||
@@ -255,6 +268,19 @@ reject/approve владением тикета не ограничены. Еди
|
|||||||
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
|
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
|
||||||
approve/reject над `ActivationRequest`.
|
approve/reject над `ActivationRequest`.
|
||||||
|
|
||||||
|
## Admin — Billing
|
||||||
|
|
||||||
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
|
| ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- |
|
||||||
|
| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays }` |
|
||||||
|
| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays }` | `BillingSettingsDto` |
|
||||||
|
| GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList<AdminPaymentRequestDto>` (включает `userName`) |
|
||||||
|
| POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (продлевает `BillingPaidUntil`, возвращает приостановленные конфиги в `Active`) |
|
||||||
|
| POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
||||||
|
|
||||||
|
То же подтверждение/отклонение доступно **из Telegram, не заходя на сайт** — инлайн-кнопки на
|
||||||
|
уведомлении о заявке (`pay:approve:{id}`/`pay:reject:{id}`, см. [telegram-bot.md](telegram-bot.md)).
|
||||||
|
|
||||||
## Admin — Nodes
|
## Admin — Nodes
|
||||||
|
|
||||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
@@ -341,9 +367,9 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
|
|||||||
| --- | -------------------------------------------------------------------- |
|
| --- | -------------------------------------------------------------------- |
|
||||||
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
|
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
|
||||||
| 401 | Нет/просрочен/невалиден access-токен |
|
| 401 | Нет/просрочен/невалиден access-токен |
|
||||||
| 403 | Нет прав по роли, либо `Auth.NotActivated` |
|
| 403 | Нет прав по роли, либо `Auth.NotActivated`, либо `Configs.BillingRequired` (просрочена оплата) |
|
||||||
| 404 | Ресурс не найден |
|
| 404 | Ресурс не найден |
|
||||||
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed` |
|
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed`, `Billing.ActiveRequestExists`, `Billing.UnlimitedRoleNotSupported`, `Telegram.NotLinked` |
|
||||||
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
|
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
|
||||||
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
|
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
|
||||||
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
|
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
(хранение/ротация/отзыв refresh-токенов), `RoleService`, `DbInitializer` (сидинг).
|
(хранение/ротация/отзыв refresh-токенов), `RoleService`, `DbInitializer` (сидинг).
|
||||||
- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; кэш клиентов per-node
|
- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; кэш клиентов per-node
|
||||||
внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет).
|
внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет).
|
||||||
- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService`, `TrafficRetentionService`
|
- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService`, `TrafficRetentionService`,
|
||||||
(`BackgroundService` + `PeriodicTimer`).
|
`BillingService` (`BackgroundService` + `PeriodicTimer`).
|
||||||
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
|
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
|
||||||
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
|
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
|
||||||
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
|
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
|
||||||
@@ -221,6 +221,10 @@ POST /api/configs
|
|||||||
- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`,
|
- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`,
|
||||||
шлёт `nodeStatusChanged` группе `admins`.
|
шлёт `nodeStatusChanged` группе `admins`.
|
||||||
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
|
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
|
||||||
|
- **BillingService** — раз в час обходит пользователей с billing-ролью (`AppRole.BillingEnabled`):
|
||||||
|
гасит конфиги при просрочке оплаты (`VpnConfig.Suspend()`), шлёт предупреждение за 3 дня до
|
||||||
|
истечения. Пропускает пользователей с `PaymentRequest` в `AwaitingConfirmation` — конфиги не
|
||||||
|
гасятся, пока админ не подтвердит/отклонит заявку (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)).
|
||||||
- Реализованы как обычные `BackgroundService` + `PeriodicTimer`, без внешнего джоб-раннера
|
- Реализованы как обычные `BackgroundService` + `PeriodicTimer`, без внешнего джоб-раннера
|
||||||
(см. [tech-stack.md](tech-stack.md)).
|
(см. [tech-stack.md](tech-stack.md)).
|
||||||
|
|
||||||
|
|||||||
+97
-9
@@ -4,11 +4,14 @@
|
|||||||
`AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser<Guid>`/
|
`AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser<Guid>`/
|
||||||
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
|
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
|
||||||
|
|
||||||
Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота:
|
Лимиты трафика на конфиг (`TrafficLimit`) не реализованы — квота на число активных конфигов —
|
||||||
число активных конфигов на роль (`AppRole.MaxConfigs`). Есть глобальная справочная цена за один
|
только через `AppRole.MaxConfigs`. Есть глобальная справочная цена за один конфиг (`PricingSettings`;
|
||||||
конфиг (`PricingSettings`; редактирует только `admin`, но справочно видна и активированным пользователям
|
редактирует только `admin`, но справочно видна и активированным пользователям в заявке на роль) —
|
||||||
в заявке на роль) — это не биллинг: без статусов оплаты, дат окончания
|
используется и биллингом (см. ниже) для расчёта суммы заявки на оплату.
|
||||||
и интеграций с платёжными системами, см. ниже.
|
|
||||||
|
**Биллинг (подписка по сроку) реализован, но опционален и включается per-роль**
|
||||||
|
(`AppRole.BillingEnabled`, недоступен для `admin`) — см. [Billing](#billing--подписка-по-сроку).
|
||||||
|
Роль без флага живёт как раньше, без ограничений по сроку.
|
||||||
|
|
||||||
## Диаграмма связей
|
## Диаграмма связей
|
||||||
|
|
||||||
@@ -105,7 +108,7 @@ AppUser
|
|||||||
| `Protocol` | `VpnProtocol` | Денормализовано с inbound |
|
| `Protocol` | `VpnProtocol` | Денормализовано с inbound |
|
||||||
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
|
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
|
||||||
| `UsedDownBytes` | `long` | Синхронизируется из 3x-ui |
|
| `UsedDownBytes` | `long` | Синхронизируется из 3x-ui |
|
||||||
| `ExpiresAt` | `DateTimeOffset?`| Зарезервировано, сейчас ничего его не выставляет — конфиг живёт бессрочно |
|
| `ExpiresAt` | `DateTimeOffset?`| Для billing-ролей — денормализованный `AppUser.BillingPaidUntil` (см. Billing); иначе `null`, конфиг живёт бессрочно. Уходит в `Subscription-Userinfo` для VPN-клиента |
|
||||||
| `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`|
|
| `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`|
|
||||||
| `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` |
|
| `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` |
|
||||||
| `LastSyncAt` | `DateTimeOffset?`| |
|
| `LastSyncAt` | `DateTimeOffset?`| |
|
||||||
@@ -122,6 +125,9 @@ AppUser
|
|||||||
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
|
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
|
||||||
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
|
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
|
||||||
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
|
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
|
||||||
|
- `Suspend()`/`Resume()` → меняют статус (`Active ↔ Expired`), отдельно от `Disable()`/`Enable()` —
|
||||||
|
приостановка за неуплату (биллинг) не должна конфликтовать с блокировкой админом: разблокировка
|
||||||
|
возвращает в `Active` только то, что было погашено именно блокировкой, и наоборот (см. Billing).
|
||||||
- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента).
|
- `Rename(label)` → юзер меняет метку (синкается в 3x-ui как имя клиента).
|
||||||
- Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по
|
- Лимит одновременных IP (`limitIp` в 3x-ui) выставляется при создании клиента (`Create`/`Rotate`) по
|
||||||
квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его
|
квоте роли пользователя (`AppRole.MaxIpLimit`; -1 = без лимита) — панель не даёт настраивать его
|
||||||
@@ -138,9 +144,9 @@ AppUser
|
|||||||
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
|
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
|
||||||
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
|
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
|
||||||
|
|
||||||
> Лимиты трафика и автоматическое истечение срока конфига не реализованы. `ExpiresAt` никогда не
|
> Лимиты трафика не реализованы. `ConfigStatus.LimitReached` в значении enum есть, но код в него
|
||||||
> выставляется; `ConfigStatus.LimitReached` в значении enum есть, но код в него никогда не переводит
|
> никогда не переводит конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см.
|
||||||
> конфиг. Квота на число конфигов реализована через `AppRole.MaxConfigs` (см. [tech-stack.md](tech-stack.md)).
|
> [tech-stack.md](tech-stack.md)). Истечение срока — только для billing-ролей, см. Billing ниже.
|
||||||
|
|
||||||
### TrafficSample — история трафика (для графиков)
|
### TrafficSample — история трафика (для графиков)
|
||||||
Точки потребления во времени; пишутся синхронизацией.
|
Точки потребления во времени; пишутся синхронизацией.
|
||||||
@@ -290,6 +296,7 @@ UI **настойчиво напоминает** привязать его (ед
|
|||||||
| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) |
|
| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) |
|
||||||
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
|
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
|
||||||
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
|
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
|
||||||
|
| `BillingEnabled` | `bool` | Включает биллинг для пользователей с этой ролью; нельзя включить для `admin` (см. Billing) |
|
||||||
|
|
||||||
Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`,
|
Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`,
|
||||||
по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2).
|
по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2).
|
||||||
@@ -348,6 +355,87 @@ PricePerConfigPerQuarter × 3`).
|
|||||||
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`,
|
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`,
|
||||||
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
|
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
|
||||||
|
|
||||||
|
### Billing — подписка по сроку
|
||||||
|
|
||||||
|
Опциональная подсистема: включается per-роль (`AppRole.BillingEnabled`), недоступна для `admin`.
|
||||||
|
Роль без флага не затрагивается — конфиги живут бессрочно, как без биллинга вообще.
|
||||||
|
|
||||||
|
**`AppUser` (доп. поля, только для billing-ролей):**
|
||||||
|
|
||||||
|
| Поле | Тип | Заметки |
|
||||||
|
| --------------------------------- | ------------------ | ------------------------------------------------------------ |
|
||||||
|
| `BillingPaidUntil` | `DateTimeOffset?` | Оплачено до этой даты; `null` — оплата ещё ни разу не выставлялась |
|
||||||
|
| `BillingSuspended` | `bool` | Конфиги приостановлены за неуплату (см. `BillingService`) |
|
||||||
|
| `BillingLastWarnedForPaidUntil` | `DateTimeOffset?` | Для какого `PaidUntil` уже отправлено предупреждение «истекает через N дней» — не даёт слать повторно на каждый тик джобы |
|
||||||
|
|
||||||
|
**Грейс-период**: когда пользователю впервые назначается billing-роль (или роли, где он уже состоит,
|
||||||
|
включают `BillingEnabled`) и `BillingPaidUntil == null` — `RoleService` выставляет
|
||||||
|
`BillingPaidUntil = now + BillingSettings.GraceDays` автоматически (`ChangeUserRoleAsync`/
|
||||||
|
`UpdateRoleAsync`). Без этого пользователь был бы «просрочен» с первой секунды.
|
||||||
|
|
||||||
|
### BillingSettings — глобальные настройки биллинга
|
||||||
|
Singleton (как `PricingSettings`) — реквизиты для оплаты и длина грейс-периода, редактирует `admin`.
|
||||||
|
|
||||||
|
| Поле | Тип | Заметки |
|
||||||
|
| ----------------- | ---------------- | ----------------------------------------------------------- |
|
||||||
|
| `Id` | `Guid` | PK |
|
||||||
|
| `RequisitesText` | `string` | Произвольный текст реквизитов (карта/крипто-адрес/СБП и т.д.), показывается пользователю с заявкой |
|
||||||
|
| `GraceDays` | `int` | По умолчанию 7 (`BillingSettings.DefaultGraceDays`) |
|
||||||
|
| `UpdatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
|
### PaymentRequest — заявка на оплату
|
||||||
|
Пользователь оформляет заявку на период (3/6/12 мес); решает админ на сайте или в Telegram. Не более
|
||||||
|
одной активной (`AwaitingPayment`/`AwaitingConfirmation`) заявки на пользователя — инвариант
|
||||||
|
проверяется в `CreatePaymentRequestCommandHandler`.
|
||||||
|
|
||||||
|
| Поле | Тип | Заметки |
|
||||||
|
| ------------------ | ----------------------- | ------------------------------------------------------------ |
|
||||||
|
| `Id` | `Guid` | PK |
|
||||||
|
| `UserId` | `Guid` | FK → AppUser (заявитель) |
|
||||||
|
| `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) |
|
||||||
|
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`. Последующее изменение прайса админом не меняет уже созданные заявки |
|
||||||
|
| `Status` | `PaymentRequestStatus` | `AwaitingPayment` → `AwaitingConfirmation` → `Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
|
||||||
|
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
|
||||||
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
|
Роль с `MaxConfigs = -1` (unlimited) не поддерживает биллинг по формуле —
|
||||||
|
`CreatePaymentRequestCommandHandler` отдаёт `BillingErrors.UnlimitedRoleNotSupported`.
|
||||||
|
|
||||||
|
**Переходы** (`backend/src/PnvPanel.Domain/Billing/PaymentRequest.cs`):
|
||||||
|
- `Create(userId, period, amount)` → `AwaitingPayment`, показываются реквизиты `BillingSettings`.
|
||||||
|
Пользователь может `Cancel()` (только из `AwaitingPayment`) или дождаться проверки.
|
||||||
|
- `MarkPaymentSent()` → пользователь нажал «Я оплатил»; `AwaitingPayment → AwaitingConfirmation`,
|
||||||
|
админам уходит Telegram-уведомление с инлайн-кнопками `pay:approve:{id}`/`pay:reject:{id}`.
|
||||||
|
- `Confirm(adminId)`/`Reject(adminId, reason)` → допустимы из **обоих** `AwaitingPayment` и
|
||||||
|
`AwaitingConfirmation` (админ мог заметить оплату раньше, чем пользователь нажал кнопку).
|
||||||
|
`Confirm` продлевает `AppUser.BillingPaidUntil = max(текущий, сейчас) + период` (не теряет уже
|
||||||
|
оплаченный остаток при досрочной оплате), возвращает в `Active` конфиги, приостановленные за
|
||||||
|
неуплату (`Suspend()`/`Resume()` на `VpnConfig`, статус `Expired`), обновляет `ExpiresAt` на всех
|
||||||
|
конфигах пользователя.
|
||||||
|
|
||||||
|
### BillingService — приостановка за неуплату (фоновая джоба)
|
||||||
|
`Infrastructure/BackgroundJobs/BillingService.cs`, раз в час (по образцу `TrafficSyncService`). Для
|
||||||
|
каждого пользователя с billing-ролью, не заблокированного (`IsBlocked`):
|
||||||
|
- есть `PaymentRequest` в статусе `AwaitingConfirmation` → **пропустить** — это и есть защита «заявка
|
||||||
|
висит на подтверждении админом, а срок истёк» из требований: конфиги не гасятся, пока админ не
|
||||||
|
решит (не по вине пользователя, что админ не успел проверить оплату);
|
||||||
|
- `BillingPaidUntil` в прошлом (или `null`) и ещё не `BillingSuspended` → приостановить все `Active`
|
||||||
|
конфиги (`Suspend()` → `Expired`, гейтвей `UpdateClientAsync(enable:false)`, идемпотентно как в
|
||||||
|
`BlockUserCommandHandler`), `AppUser.BillingSuspended = true`, Telegram-уведомление пользователю,
|
||||||
|
`AuditLog` (`BillingSuspended`, источник `System`). На последующих тиках (уже suspended) — только
|
||||||
|
идемпотентная досуспензия «зависших» `Active`-конфигов (самовосстановление после недоступности
|
||||||
|
ноды), без повторных уведомлений;
|
||||||
|
- до истечения ≤ 3 дней и предупреждение для этого `PaidUntil` ещё не отправлено
|
||||||
|
(`BillingLastWarnedForPaidUntil != PaidUntil`) → Telegram-предупреждение, отметка отправки.
|
||||||
|
|
||||||
|
`CreateVpnConfigCommandHandler` дополнительно не даёт создать **новый** конфиг, если роль billing
|
||||||
|
и оплата просрочена (`ConfigErrors.BillingRequired`) — иначе приостановку можно было бы обойти
|
||||||
|
созданием свежего конфига.
|
||||||
|
|
||||||
|
`GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка
|
||||||
|
реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок,
|
||||||
|
подтверждение/отклонение), только `admin`.
|
||||||
|
|
||||||
### ActivationRequest — запрос активации
|
### ActivationRequest — запрос активации
|
||||||
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -75,7 +75,8 @@
|
|||||||
| -------------------------- | --------------------------------------------------------------------------------- |
|
| -------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) |
|
| Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) |
|
||||||
| Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) |
|
| Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) |
|
||||||
| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. Есть глобальная справочная цена за конфиг (`PricingSettings`, редактирует только `admin`, справочно видна и в заявке на роль) — без биллинг-логики |
|
| Лимиты трафика | Не реализованы — конфиги без лимитов трафика. Есть глобальная справочная цена за конфиг (`PricingSettings`, редактирует только `admin`) — ставка ₽/конфиг/месяц по периодам 3/6/12 мес |
|
||||||
|
| Биллинг (подписка по сроку) | Реализован, но **включается per-роль** (`AppRole.BillingEnabled`, недоступен для `admin`) — см. [domain-model.md](domain-model.md#billing--подписка-по-сроку). Не биллинг-система по умолчанию: роль без флага живёт без ограничений по сроку, как раньше |
|
||||||
| i18n | RU + EN (react-i18next) |
|
| i18n | RU + EN (react-i18next) |
|
||||||
| Telegram-транспорт | Long polling |
|
| Telegram-транспорт | Long polling |
|
||||||
| Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) |
|
| Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) |
|
||||||
|
|||||||
@@ -197,6 +197,30 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на
|
4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на
|
||||||
роль отклонена.».
|
роль отклонена.».
|
||||||
|
|
||||||
|
## Флоу 7 — Оплата подписки (биллинг)
|
||||||
|
|
||||||
|
Только для ролей с `AppRole.BillingEnabled` (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)).
|
||||||
|
Заявка (`PaymentRequest`) заводится и решается частично на сайте, частично в Telegram — симметрично
|
||||||
|
заявке на роль:
|
||||||
|
|
||||||
|
1. Пользователь создаёт заявку и жмёт «Я оплатил» на сайте (`/billing`) — бот в это не вовлечён,
|
||||||
|
кроме опциональной кнопки «Отправить реквизиты в Telegram» (DM самому себе для удобства, статус
|
||||||
|
заявки не меняет).
|
||||||
|
2. `MarkPaymentSentCommandHandler` вызывает
|
||||||
|
`ITelegramNotifier.NotifyAdminsPaymentRequestedAsync(requestId, userName, period, amount, ct)`.
|
||||||
|
3. Сообщение с кнопками **«✅ Подтвердить» / «❌ Отклонить»** (callback `pay:approve:{id}`/`pay:reject:{id}`
|
||||||
|
— тот же 3-частный формат, что и `rrq:*`/`act:*`).
|
||||||
|
4. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`) → `ConfirmPaymentRequestCommand`/
|
||||||
|
`RejectPaymentRequestCommand` (те же команды, что дёргает `POST /api/admin/billing/requests/{id}/confirm|reject`
|
||||||
|
на сайте). Подтверждение продлевает `AppUser.BillingPaidUntil` и возвращает приостановленные
|
||||||
|
конфиги в `Active`. Исходное сообщение редактируется (дописывается статус), как у `rrq:*`/`act:*`.
|
||||||
|
5. Пользователю (если Telegram привязан) — DM «✅ Оплата подтверждена. Доступ продлён до {дата}.» /
|
||||||
|
«❌ Заявка на оплату отклонена.».
|
||||||
|
|
||||||
|
Отдельно, фоновая `BillingService` (не через бота) шлёт DM-предупреждение за 3 дня до истечения
|
||||||
|
оплаты и уведомление о приостановке конфигов при просрочке — оба через `NotifyUserAsync`, без
|
||||||
|
инлайн-кнопок.
|
||||||
|
|
||||||
## Команды и клавиатуры
|
## Команды и клавиатуры
|
||||||
|
|
||||||
| Команда / кнопка | Действие | Требует привязки |
|
| Команда / кнопка | Действие | Требует привязки |
|
||||||
@@ -213,6 +237,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||||||
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
||||||
| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env |
|
| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env |
|
||||||
|
| «✅ Подтвердить»/«❌ Отклонить» (`pay:*`) | (admin) решение по заявке на оплату — продлевает `BillingPaidUntil` | админ по env |
|
||||||
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env |
|
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env |
|
||||||
|
|
||||||
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.
|
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.
|
||||||
|
|||||||
+3
-2
@@ -75,8 +75,9 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
|
|||||||
|
|
||||||
### U2. Пользователь следит за трафиком
|
### U2. Пользователь следит за трафиком
|
||||||
- Фоновая синхронизация тянет трафик из 3x-ui; изменения приходят в UI через SignalR (без перезагрузки).
|
- Фоновая синхронизация тянет трафик из 3x-ui; изменения приходят в UI через SignalR (без перезагрузки).
|
||||||
- Это только отображение: лимиты по трафику/сроку конфига не реализованы — единственная квота —
|
- Это только отображение: лимиты по трафику не реализованы — единственная квота — число активных
|
||||||
число активных конфигов на роль. Конфиг живёт, пока его явно не отзовут.
|
конфигов на роль. Конфиг живёт, пока его явно не отзовут — если только его роль не подписана на
|
||||||
|
биллинг (см. domain-model.md), тогда неоплаченный конфиг может быть временно приостановлен.
|
||||||
|
|
||||||
### A1. Админ подключает ноду и публикует инбаунды
|
### A1. Админ подключает ноду и публикует инбаунды
|
||||||
1. Вводит адрес панели 3x-ui, логин/пароль (шифруются при хранении).
|
1. Вводит адрес панели 3x-ui, логин/пароль (шифруются при хранении).
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import type { BillingSettingsDto } from '@/shared/api/types'
|
||||||
|
import { updateBillingSettings } from './api'
|
||||||
|
|
||||||
|
export function BillingSettingsEditor({ settings }: { settings: BillingSettingsDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [requisitesText, setRequisitesText] = useState(settings.requisitesText)
|
||||||
|
const [graceDays, setGraceDays] = useState(String(settings.graceDays))
|
||||||
|
const [defaultBillingEnabledForNewRoles, setDefaultBillingEnabledForNewRoles] = useState(
|
||||||
|
settings.defaultBillingEnabledForNewRoles,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateBillingSettings(requisitesText.trim(), Number(graceDays), defaultBillingEnabledForNewRoles),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('admin.billing.settingsUpdated'))
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-billing-settings'] })
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
mutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="requisitesText">{t('admin.billing.requisitesText')}</Label>
|
||||||
|
<textarea
|
||||||
|
id="requisitesText"
|
||||||
|
className="min-h-24 rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
value={requisitesText}
|
||||||
|
onChange={(e) => setRequisitesText(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.billing.requisitesTextHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="graceDays">{t('admin.billing.graceDays')}</Label>
|
||||||
|
<Input
|
||||||
|
id="graceDays"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
value={graceDays}
|
||||||
|
onChange={(e) => setGraceDays(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.billing.graceDaysHint')}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={defaultBillingEnabledForNewRoles}
|
||||||
|
onChange={(e) => setDefaultBillingEnabledForNewRoles(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('admin.billing.defaultBillingEnabledForNewRolesLabel')}
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<Button type="submit" disabled={mutation.isPending || !requisitesText.trim()}>
|
||||||
|
{t('admin.roles.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { apiRequest } from '@/shared/api/client'
|
||||||
|
import type {
|
||||||
|
AdminPaymentRequestDto,
|
||||||
|
BillingSettingsDto,
|
||||||
|
PagedList,
|
||||||
|
PaymentRequestStatus,
|
||||||
|
} from '@/shared/api/types'
|
||||||
|
|
||||||
|
export function getBillingSettings() {
|
||||||
|
return apiRequest<BillingSettingsDto>('/admin/billing/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBillingSettings(
|
||||||
|
requisitesText: string,
|
||||||
|
graceDays: number,
|
||||||
|
defaultBillingEnabledForNewRoles: boolean,
|
||||||
|
) {
|
||||||
|
return apiRequest<BillingSettingsDto>('/admin/billing/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { requisitesText, graceDays, defaultBillingEnabledForNewRoles },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPaymentRequests(status?: PaymentRequestStatus, page = 1, pageSize = 20) {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||||
|
if (status) params.set('status', status)
|
||||||
|
return apiRequest<PagedList<AdminPaymentRequestDto>>(`/admin/billing/requests?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmPaymentRequest(id: string) {
|
||||||
|
return apiRequest<void>(`/admin/billing/requests/${id}/confirm`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectPaymentRequest(id: string, reason?: string) {
|
||||||
|
return apiRequest<void>(`/admin/billing/requests/${id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { reason: reason ?? null },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { RoleDto } from '@/shared/api/types'
|
import type { RoleDto } from '@/shared/api/types'
|
||||||
|
import { getBillingSettings } from '@/features/admin/billing/api'
|
||||||
import { createRole, updateRole } from './api'
|
import { createRole, updateRole } from './api'
|
||||||
|
|
||||||
/** Без role — диалог создания (кнопка-триггер); с role — диалог редактирования квоты (управляется извне). */
|
/** Без role — диалог создания (кнопка-триггер); с role — диалог редактирования квоты (управляется извне). */
|
||||||
@@ -24,8 +26,25 @@ export function RoleFormDialog({
|
|||||||
const [name, setName] = useState(role?.name ?? '')
|
const [name, setName] = useState(role?.name ?? '')
|
||||||
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
|
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
|
||||||
const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2))
|
const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2))
|
||||||
|
const [billingEnabled, setBillingEnabled] = useState(role?.billingEnabled ?? false)
|
||||||
const [internalOpen, setInternalOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
|
|
||||||
|
// Роль admin — системная и всегда без биллинга (см. RoleService.UpdateRoleAsync на бэке).
|
||||||
|
const isAdminRole = role?.name === 'admin'
|
||||||
|
const isCreating = !role
|
||||||
|
|
||||||
|
// Для новой роли подставляем дефолт из настроек биллинга (BillingSettings.DefaultBillingEnabledForNewRoles) —
|
||||||
|
// чистое удобство админа, не переопределяет то, что он вручную поменяет в форме.
|
||||||
|
const billingSettingsQuery = useQuery({
|
||||||
|
queryKey: ['admin-billing-settings'],
|
||||||
|
queryFn: getBillingSettings,
|
||||||
|
enabled: isCreating,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreating && billingSettingsQuery.data)
|
||||||
|
setBillingEnabled(billingSettingsQuery.data.defaultBillingEnabledForNewRoles)
|
||||||
|
}, [isCreating, billingSettingsQuery.data])
|
||||||
|
|
||||||
const isControlled = open !== undefined
|
const isControlled = open !== undefined
|
||||||
const dialogOpen = isControlled ? open : internalOpen
|
const dialogOpen = isControlled ? open : internalOpen
|
||||||
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
||||||
@@ -33,8 +52,8 @@ export function RoleFormDialog({
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
role
|
role
|
||||||
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit))
|
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit), billingEnabled)
|
||||||
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit)),
|
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit), billingEnabled),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
|
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
|
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
|
||||||
@@ -42,8 +61,9 @@ export function RoleFormDialog({
|
|||||||
setName('')
|
setName('')
|
||||||
setMaxConfigs('3')
|
setMaxConfigs('3')
|
||||||
setMaxIpLimit('2')
|
setMaxIpLimit('2')
|
||||||
|
setBillingEnabled(false)
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t('auth.genericError')),
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,6 +100,16 @@ export function RoleFormDialog({
|
|||||||
<Input id="maxIpLimit" type="number" value={maxIpLimit} onChange={(e) => setMaxIpLimit(e.target.value)} />
|
<Input id="maxIpLimit" type="number" value={maxIpLimit} onChange={(e) => setMaxIpLimit(e.target.value)} />
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.roles.maxIpLimitHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.roles.maxIpLimitHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!isAdminRole && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={billingEnabled}
|
||||||
|
onChange={(e) => setBillingEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('admin.roles.billingEnabledLabel')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
|
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
|
||||||
{role ? t('admin.roles.save') : t('admin.roles.create')}
|
{role ? t('admin.roles.save') : t('admin.roles.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ export function listRoles() {
|
|||||||
return apiRequest<RoleDto[]>('/admin/roles')
|
return apiRequest<RoleDto[]>('/admin/roles')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRole(name: string, maxConfigs: number, maxIpLimit: number) {
|
export function createRole(name: string, maxConfigs: number, maxIpLimit: number, billingEnabled: boolean) {
|
||||||
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } })
|
return apiRequest<RoleDto>('/admin/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, maxConfigs, maxIpLimit, billingEnabled },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) {
|
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number, billingEnabled: boolean) {
|
||||||
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } })
|
return apiRequest<RoleDto>(`/admin/roles/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { maxConfigs, maxIpLimit, billingEnabled },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRole(id: string) {
|
export function deleteRole(id: string) {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import type { BillingStatusDto, PaymentPeriod } from '@/shared/api/types'
|
||||||
|
import { cancelPaymentRequest, createPaymentRequest, markPaymentSent, sendRequisitesToTelegram } from './api'
|
||||||
|
|
||||||
|
const PERIODS: PaymentPeriod[] = ['Quarter', 'HalfYear', 'Year']
|
||||||
|
|
||||||
|
export function PaymentRequestPanel({ status }: { status: BillingStatusDto }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [period, setPeriod] = useState<PaymentPeriod>('Quarter')
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['my-billing-status'] })
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createPaymentRequest(period),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('billing.requestCreated'))
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => cancelPaymentRequest(id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('billing.requestCancelled'))
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const markPaidMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => markPaymentSent(id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('billing.markedPaid'))
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendToTelegramMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => sendRequisitesToTelegram(id),
|
||||||
|
onSuccess: () => toast.success(t('billing.requisitesSent')),
|
||||||
|
onError: (error) => {
|
||||||
|
const message =
|
||||||
|
error instanceof HttpError && error.status === 409 ? t('billing.telegramNotLinked') : t('auth.genericError')
|
||||||
|
toast.error(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = status.activeRequest
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('billing.newRequest')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('billing.period')}</Label>
|
||||||
|
<Select value={period} onValueChange={(v) => setPeriod(v as PaymentPeriod)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{t(`billing.periods.${p}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
|
||||||
|
{t('billing.createRequest')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAwaitingConfirmation = request.status === 'AwaitingConfirmation'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="text-base">{t('billing.activeRequest')}</CardTitle>
|
||||||
|
<Badge variant={isAwaitingConfirmation ? 'warning' : 'outline'}>
|
||||||
|
{t(`billing.requestStatus.${request.status}`)}
|
||||||
|
</Badge>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
{t(`billing.periods.${request.period}`)} — <span className="font-medium">{request.amountSnapshot} ₽</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!isAwaitingConfirmation && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('billing.requisites')}</Label>
|
||||||
|
<p className="whitespace-pre-wrap rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
||||||
|
{status.requisitesText || t('billing.requisitesMissing')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => sendToTelegramMutation.mutate(request.id)}
|
||||||
|
disabled={sendToTelegramMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('billing.sendToTelegram')}
|
||||||
|
</Button>
|
||||||
|
{!isAwaitingConfirmation && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => markPaidMutation.mutate(request.id)}
|
||||||
|
disabled={markPaidMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('billing.iPaid')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(t('billing.confirmCancel'))) cancelMutation.mutate(request.id)
|
||||||
|
}}
|
||||||
|
disabled={cancelMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('billing.cancelRequest')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAwaitingConfirmation && <p className="text-xs text-muted-foreground">{t('billing.awaitingAdminHint')}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { apiRequest } from '@/shared/api/client'
|
||||||
|
import type { BillingStatusDto, PaymentPeriod, PaymentRequestDto } from '@/shared/api/types'
|
||||||
|
|
||||||
|
export function getMyBillingStatus() {
|
||||||
|
return apiRequest<BillingStatusDto>('/billing/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaymentRequest(period: PaymentPeriod) {
|
||||||
|
return apiRequest<PaymentRequestDto>('/billing/requests', { method: 'POST', body: { period } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelPaymentRequest(id: string) {
|
||||||
|
return apiRequest<void>(`/billing/requests/${id}/cancel`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPaymentSent(id: string) {
|
||||||
|
return apiRequest<void>(`/billing/requests/${id}/mark-paid`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendRequisitesToTelegram(id: string) {
|
||||||
|
return apiRequest<void>(`/billing/requests/${id}/send-requisites`, { method: 'POST' })
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { Route as NewsRouteImport } from './routes/news'
|
|||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as InstructionsRouteImport } from './routes/instructions'
|
import { Route as InstructionsRouteImport } from './routes/instructions'
|
||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
|
import { Route as BillingRouteImport } from './routes/billing'
|
||||||
import { Route as AdminRouteImport } from './routes/admin'
|
import { Route as AdminRouteImport } from './routes/admin'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||||
@@ -28,6 +29,7 @@ import { Route as AdminNewsRouteImport } from './routes/admin/news'
|
|||||||
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
||||||
import { Route as AdminInstructionsRouteImport } from './routes/admin/instructions'
|
import { Route as AdminInstructionsRouteImport } from './routes/admin/instructions'
|
||||||
import { Route as AdminConfigsRouteImport } from './routes/admin/configs'
|
import { Route as AdminConfigsRouteImport } from './routes/admin/configs'
|
||||||
|
import { Route as AdminBillingRouteImport } from './routes/admin/billing'
|
||||||
import { Route as AdminAuditRouteImport } from './routes/admin/audit'
|
import { Route as AdminAuditRouteImport } from './routes/admin/audit'
|
||||||
import { Route as AdminAppsRouteImport } from './routes/admin/apps'
|
import { Route as AdminAppsRouteImport } from './routes/admin/apps'
|
||||||
import { Route as AdminActivationRouteImport } from './routes/admin/activation'
|
import { Route as AdminActivationRouteImport } from './routes/admin/activation'
|
||||||
@@ -67,6 +69,11 @@ const DashboardRoute = DashboardRouteImport.update({
|
|||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const BillingRoute = BillingRouteImport.update({
|
||||||
|
id: '/billing',
|
||||||
|
path: '/billing',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AdminRoute = AdminRouteImport.update({
|
const AdminRoute = AdminRouteImport.update({
|
||||||
id: '/admin',
|
id: '/admin',
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
@@ -127,6 +134,11 @@ const AdminConfigsRoute = AdminConfigsRouteImport.update({
|
|||||||
path: '/configs',
|
path: '/configs',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminBillingRoute = AdminBillingRouteImport.update({
|
||||||
|
id: '/billing',
|
||||||
|
path: '/billing',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminAuditRoute = AdminAuditRouteImport.update({
|
const AdminAuditRoute = AdminAuditRouteImport.update({
|
||||||
id: '/audit',
|
id: '/audit',
|
||||||
path: '/audit',
|
path: '/audit',
|
||||||
@@ -146,6 +158,7 @@ const AdminActivationRoute = AdminActivationRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRouteWithChildren
|
'/admin': typeof AdminRouteWithChildren
|
||||||
|
'/billing': typeof BillingRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/instructions': typeof InstructionsRoute
|
'/instructions': typeof InstructionsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -156,6 +169,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/activation': typeof AdminActivationRoute
|
'/admin/activation': typeof AdminActivationRoute
|
||||||
'/admin/apps': typeof AdminAppsRoute
|
'/admin/apps': typeof AdminAppsRoute
|
||||||
'/admin/audit': typeof AdminAuditRoute
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
|
'/admin/billing': typeof AdminBillingRoute
|
||||||
'/admin/configs': typeof AdminConfigsRoute
|
'/admin/configs': typeof AdminConfigsRoute
|
||||||
'/admin/instructions': typeof AdminInstructionsRoute
|
'/admin/instructions': typeof AdminInstructionsRoute
|
||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
@@ -169,6 +183,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/billing': typeof BillingRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/instructions': typeof InstructionsRoute
|
'/instructions': typeof InstructionsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -179,6 +194,7 @@ export interface FileRoutesByTo {
|
|||||||
'/admin/activation': typeof AdminActivationRoute
|
'/admin/activation': typeof AdminActivationRoute
|
||||||
'/admin/apps': typeof AdminAppsRoute
|
'/admin/apps': typeof AdminAppsRoute
|
||||||
'/admin/audit': typeof AdminAuditRoute
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
|
'/admin/billing': typeof AdminBillingRoute
|
||||||
'/admin/configs': typeof AdminConfigsRoute
|
'/admin/configs': typeof AdminConfigsRoute
|
||||||
'/admin/instructions': typeof AdminInstructionsRoute
|
'/admin/instructions': typeof AdminInstructionsRoute
|
||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
@@ -194,6 +210,7 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRouteWithChildren
|
'/admin': typeof AdminRouteWithChildren
|
||||||
|
'/billing': typeof BillingRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/instructions': typeof InstructionsRoute
|
'/instructions': typeof InstructionsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -204,6 +221,7 @@ export interface FileRoutesById {
|
|||||||
'/admin/activation': typeof AdminActivationRoute
|
'/admin/activation': typeof AdminActivationRoute
|
||||||
'/admin/apps': typeof AdminAppsRoute
|
'/admin/apps': typeof AdminAppsRoute
|
||||||
'/admin/audit': typeof AdminAuditRoute
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
|
'/admin/billing': typeof AdminBillingRoute
|
||||||
'/admin/configs': typeof AdminConfigsRoute
|
'/admin/configs': typeof AdminConfigsRoute
|
||||||
'/admin/instructions': typeof AdminInstructionsRoute
|
'/admin/instructions': typeof AdminInstructionsRoute
|
||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
@@ -220,6 +238,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
|
| '/billing'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/instructions'
|
| '/instructions'
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -230,6 +249,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/activation'
|
| '/admin/activation'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
|
| '/admin/billing'
|
||||||
| '/admin/configs'
|
| '/admin/configs'
|
||||||
| '/admin/instructions'
|
| '/admin/instructions'
|
||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
@@ -243,6 +263,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/billing'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/instructions'
|
| '/instructions'
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -253,6 +274,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/activation'
|
| '/admin/activation'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
|
| '/admin/billing'
|
||||||
| '/admin/configs'
|
| '/admin/configs'
|
||||||
| '/admin/instructions'
|
| '/admin/instructions'
|
||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
@@ -267,6 +289,7 @@ export interface FileRouteTypes {
|
|||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
|
| '/billing'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/instructions'
|
| '/instructions'
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -277,6 +300,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/activation'
|
| '/admin/activation'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
|
| '/admin/billing'
|
||||||
| '/admin/configs'
|
| '/admin/configs'
|
||||||
| '/admin/instructions'
|
| '/admin/instructions'
|
||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
@@ -292,6 +316,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AdminRoute: typeof AdminRouteWithChildren
|
AdminRoute: typeof AdminRouteWithChildren
|
||||||
|
BillingRoute: typeof BillingRoute
|
||||||
DashboardRoute: typeof DashboardRoute
|
DashboardRoute: typeof DashboardRoute
|
||||||
InstructionsRoute: typeof InstructionsRoute
|
InstructionsRoute: typeof InstructionsRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
@@ -352,6 +377,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardRouteImport
|
preLoaderRoute: typeof DashboardRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/billing': {
|
||||||
|
id: '/billing'
|
||||||
|
path: '/billing'
|
||||||
|
fullPath: '/billing'
|
||||||
|
preLoaderRoute: typeof BillingRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/admin': {
|
'/admin': {
|
||||||
id: '/admin'
|
id: '/admin'
|
||||||
path: '/admin'
|
path: '/admin'
|
||||||
@@ -436,6 +468,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminConfigsRouteImport
|
preLoaderRoute: typeof AdminConfigsRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/billing': {
|
||||||
|
id: '/admin/billing'
|
||||||
|
path: '/billing'
|
||||||
|
fullPath: '/admin/billing'
|
||||||
|
preLoaderRoute: typeof AdminBillingRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/audit': {
|
'/admin/audit': {
|
||||||
id: '/admin/audit'
|
id: '/admin/audit'
|
||||||
path: '/audit'
|
path: '/audit'
|
||||||
@@ -464,6 +503,7 @@ interface AdminRouteChildren {
|
|||||||
AdminActivationRoute: typeof AdminActivationRoute
|
AdminActivationRoute: typeof AdminActivationRoute
|
||||||
AdminAppsRoute: typeof AdminAppsRoute
|
AdminAppsRoute: typeof AdminAppsRoute
|
||||||
AdminAuditRoute: typeof AdminAuditRoute
|
AdminAuditRoute: typeof AdminAuditRoute
|
||||||
|
AdminBillingRoute: typeof AdminBillingRoute
|
||||||
AdminConfigsRoute: typeof AdminConfigsRoute
|
AdminConfigsRoute: typeof AdminConfigsRoute
|
||||||
AdminInstructionsRoute: typeof AdminInstructionsRoute
|
AdminInstructionsRoute: typeof AdminInstructionsRoute
|
||||||
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
||||||
@@ -480,6 +520,7 @@ const AdminRouteChildren: AdminRouteChildren = {
|
|||||||
AdminActivationRoute: AdminActivationRoute,
|
AdminActivationRoute: AdminActivationRoute,
|
||||||
AdminAppsRoute: AdminAppsRoute,
|
AdminAppsRoute: AdminAppsRoute,
|
||||||
AdminAuditRoute: AdminAuditRoute,
|
AdminAuditRoute: AdminAuditRoute,
|
||||||
|
AdminBillingRoute: AdminBillingRoute,
|
||||||
AdminConfigsRoute: AdminConfigsRoute,
|
AdminConfigsRoute: AdminConfigsRoute,
|
||||||
AdminInstructionsRoute: AdminInstructionsRoute,
|
AdminInstructionsRoute: AdminInstructionsRoute,
|
||||||
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
||||||
@@ -497,6 +538,7 @@ const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AdminRoute: AdminRouteWithChildren,
|
AdminRoute: AdminRouteWithChildren,
|
||||||
|
BillingRoute: BillingRoute,
|
||||||
DashboardRoute: DashboardRoute,
|
DashboardRoute: DashboardRoute,
|
||||||
InstructionsRoute: InstructionsRoute,
|
InstructionsRoute: InstructionsRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, Outlet, createRootRoute, useRouterState } from '@tanstack/react-router'
|
import { Link, Outlet, createRootRoute, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Menu, X } from 'lucide-react'
|
import { Menu, X } from 'lucide-react'
|
||||||
import { useTheme, type Theme } from '@/theme/ThemeProvider'
|
import { useTheme, type Theme } from '@/theme/ThemeProvider'
|
||||||
@@ -9,6 +10,7 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { useAuthStore } from '@/features/auth/store'
|
import { useAuthStore } from '@/features/auth/store'
|
||||||
import { bootstrapSession, clearSession, logout } from '@/features/auth/api'
|
import { bootstrapSession, clearSession, logout } from '@/features/auth/api'
|
||||||
import { TelegramLinkWarningBanner } from '@/features/telegram/TelegramLinkWarningBanner'
|
import { TelegramLinkWarningBanner } from '@/features/telegram/TelegramLinkWarningBanner'
|
||||||
|
import { getMyBillingStatus } from '@/features/billing/api'
|
||||||
|
|
||||||
export const Route = createRootRoute({ component: RootLayout })
|
export const Route = createRootRoute({ component: RootLayout })
|
||||||
|
|
||||||
@@ -19,6 +21,15 @@ function RootLayout() {
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
|
||||||
|
// Гейтится наличием сессии и активации — та же логика, что и /billing (ActivationGate). Долгий
|
||||||
|
// staleTime: это лишь решение "показывать ли пункт меню", а не источник актуального статуса оплаты.
|
||||||
|
const billingStatusQuery = useQuery({
|
||||||
|
queryKey: ['my-billing-status'],
|
||||||
|
queryFn: getMyBillingStatus,
|
||||||
|
enabled: !isBootstrapping && !!user?.isActivated,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void bootstrapSession()
|
void bootstrapSession()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -55,6 +66,11 @@ function RootLayout() {
|
|||||||
<Link to="/support" className="text-muted-foreground hover:text-foreground">
|
<Link to="/support" className="text-muted-foreground hover:text-foreground">
|
||||||
{t('nav.support')}
|
{t('nav.support')}
|
||||||
</Link>
|
</Link>
|
||||||
|
{billingStatusQuery.data?.billingEnabled && (
|
||||||
|
<Link to="/billing" className="text-muted-foreground hover:text-foreground">
|
||||||
|
{t('nav.billing')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Link to="/settings" className="text-muted-foreground hover:text-foreground">
|
<Link to="/settings" className="text-muted-foreground hover:text-foreground">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const TABS = [
|
|||||||
{ to: '/admin/configs', key: 'configs' },
|
{ to: '/admin/configs', key: 'configs' },
|
||||||
{ to: '/admin/roles', key: 'roles' },
|
{ to: '/admin/roles', key: 'roles' },
|
||||||
{ to: '/admin/pricing', key: 'pricing' },
|
{ to: '/admin/pricing', key: 'pricing' },
|
||||||
|
{ to: '/admin/billing', key: 'billing' },
|
||||||
{ to: '/admin/nodes', key: 'nodes' },
|
{ to: '/admin/nodes', key: 'nodes' },
|
||||||
{ to: '/admin/apps', key: 'apps' },
|
{ to: '/admin/apps', key: 'apps' },
|
||||||
{ to: '/admin/instructions', key: 'instructions' },
|
{ to: '/admin/instructions', key: 'instructions' },
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user