Enhance user plan management and update related endpoints
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`.
- Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes.
- Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users.
- Removed deprecated role request approval endpoints from `AdminSupportEndpoints`.
- Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications.
- Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings.
- Enhanced billing request handling to accommodate plan changes instead of role changes.
- Updated various interfaces and command handlers to support new plan management features.
This commit is contained in:
Leonid Pershin committed 2026-07-23 22:52:20 +03:00
1 parent 2c5b730500
commit fad03c2834
152 files changed
+4060 -2240

No files matched your search

+35 -12
View File
@@ -93,7 +93,8 @@ export type MySubscriptionDto = {
export type GetMyConfigsResult = {
configs: VpnConfigDto[]
maxConfigs: number
configQuota: number
planId: string | null
}
export type ClientAppDto = {
@@ -175,13 +176,40 @@ export type UserSummaryDto = {
export type RoleDto = {
id: string
name: string
maxConfigs: number
maxIpLimit: number
isSystem: boolean
billingEnabled: boolean
}
/** Одна ступень скидочной лесенки за объём: роль с квотой maxConfigs >= minConfigs получает скидку
export type PlanDto = {
id: string
name: string
configCount: number
}
export type AdminPlanDto = {
id: string
name: string
configCount: number
sortOrder: number
isEnabled: boolean
}
export type MyPlanStatusDto = {
configQuota: number
planId: string | null
activeConfigCount: number
billingEnabled: boolean
billingPaidUntil: string | null
}
export type ChangePlanResultDto = {
configQuota: number
planId: string | null
topUpAmount: number | null
}
/** Одна ступень скидочной лесенки за объём: тариф с количеством конфигов >= minConfigs получает скидку
* discountPercent от итоговой цены периода. Действует наивысший подходящий порог (не суммируется). */
export type DiscountTierDto = {
minConfigs: number
@@ -205,9 +233,9 @@ export type PaymentRequestStatus =
| 'Confirmed'
| 'Rejected'
| 'Cancelled'
/** Subscription — оплата за период (period задан). RoleChangeTopUp — доплата разницы в цене при
* апгрейде роли с активным оплаченным периодом (period null, не продлевает paidUntil). */
export type PaymentRequestKind = 'Subscription' | 'RoleChangeTopUp'
/** Subscription — оплата за период (period задан). PlanChangeTopUp — доплата разницы в цене при
* увеличении тарифа с активным оплаченным периодом (period null, не продлевает paidUntil). */
export type PaymentRequestKind = 'Subscription' | 'PlanChangeTopUp'
export type PaymentRequestDto = {
id: string
@@ -328,7 +356,7 @@ export type AuditLogDto = {
createdAt: string
}
export type TicketType = 'BugReport' | 'RoleRequest' | 'ExtensionRequest'
export type TicketType = 'BugReport' | 'ExtensionRequest'
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
export type TicketAttachmentDto = {
@@ -369,11 +397,6 @@ export type TicketDetailDto = {
userName: string
type: TicketType
status: TicketStatus
requestedRoleId: string | null
requestedRoleName: string | null
proposedRoleName: string | null
proposedMaxConfigs: number | null
proposedMaxIpLimit: number | null
requestedDays: number | null
createdAt: string
comments: TicketCommentDto[]
+90 -56
View File
@@ -81,7 +81,7 @@ const resources = {
billingRequiredNotice: 'Оплата подписки истекла — создание новых конфигов недоступно, пока не продлите доступ.',
goToBilling: 'Перейти к оплате',
created: 'Конфиг создан.',
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
quotaExceeded: 'Достигнут лимит конфигов для вашего тарифа.',
showLink: 'Ссылка / QR',
rotate: 'Перевыпустить',
rotated: 'Конфиг перевыпущен.',
@@ -103,6 +103,26 @@ const resources = {
},
},
plan: {
title: 'Тариф',
changePlan: 'Изменить тариф',
current: 'Текущая квота: {{count}} конфигов',
currentUnlimited: 'Текущая квота: без лимита',
custom: 'Своё количество',
customConfigCount: 'Количество конфигов',
submit: 'Применить',
changed: 'Тариф изменён.',
downgradeNotice: 'Новая квота меньше текущего числа активных конфигов — выберите, какие отозвать:',
selectExactly: 'Нужно выбрать ровно {{count}} конфиг(ов) для отзыва.',
topUpCreated: 'Тариф увеличен. Создана доплата за оставшийся оплаченный период.',
goToBilling: 'Перейти к оплате',
mustSelectConfigs: 'Выберите конфиги для отзыва.',
perQuarter: '3 мес: {{price}}',
perHalfYear: 'Полгода: {{price}}',
perYear: 'Год: {{price}}',
noPrice: '—',
},
billing: {
title: 'Оплата подписки',
notApplicable: 'Биллинг не применяется к вашей роли.',
@@ -147,8 +167,8 @@ const resources = {
confirmCancel: 'Отменить заявку на оплату?',
requestCancelled: 'Заявка отменена.',
awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.',
roleChangeTopUp: 'Доплата за смену роли',
roleChangeTopUpHint: 'Новая роль дороже прежней — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
planChangeTopUp: 'Доплата за смену тарифа',
planChangeTopUpHint: 'Новый тариф дороже прежнего — эта сумма покрывает разницу в цене за оставшуюся часть уже оплаченного периода, срок подписки при этом не меняется.',
subscription: 'Подписка',
},
@@ -175,7 +195,6 @@ const resources = {
title: 'Поддержка',
empty: 'У вас пока нет обращений.',
reportBug: 'Сообщить об ошибке',
requestRole: 'Запросить роль',
requestExtension: 'Попросить о продлении',
requestedDaysLabel: 'Сколько дней нужно',
requestedExtension: 'Запрошено продление на {{days}} дн.',
@@ -184,32 +203,15 @@ const resources = {
messageLabel: 'Опишите проблему или предложение',
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
filesSelected: '{{count}} файл(ов) выбрано',
existingRole: 'Существующая роль',
newRole: 'Новая роль',
selectRole: 'Выберите роль',
roleOption: '{{name}} — конфигов: {{configs}}, IP: {{ip}}',
newRoleName: 'Название роли',
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
pricingTitle: 'Ориентировочная стоимость',
pricingQuarter: '3 месяца: {{price}}',
pricingHalfYear: 'Полгода: {{price}}',
pricingYear: 'Год: {{price}}',
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
pricingDiscounted: '{{price}} ₽ (вместо {{original}} ₽, скидка {{percent}}%)',
justification: 'Обоснование',
ticketCreated: 'Обращение отправлено.',
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
reply: 'Ответить',
replyPlaceholder: 'Написать комментарий…',
reopen: 'Переоткрыть',
reopened: 'Тикет переоткрыт.',
lastActivity: 'Последняя активность: {{date}}',
requestedExistingRole: 'Запрошена роль: {{role}}',
requestedNewRole: 'Запрошена новая роль «{{name}}» (конфигов: {{configs}}, IP: {{ip}})',
type: {
BugReport: 'Ошибка/предложение',
RoleRequest: 'Заявка на роль',
ExtensionRequest: 'Заявка на продление',
},
status: {
@@ -254,6 +256,7 @@ const resources = {
users: 'Пользователи',
configs: 'Все конфиги',
roles: 'Роли',
plans: 'Тарифы',
pricing: 'Цены',
billing: 'Оплата',
nodes: 'Ноды',
@@ -331,25 +334,39 @@ const resources = {
roles: {
create: 'Создать роль',
name: 'Название',
maxConfigs: 'Квота конфигов',
maxConfigsHint: '−1 = без лимита.',
maxIpLimit: 'Лимит IP на конфиг',
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
totalPerQuarter: 'Итого / 3 мес',
totalHalfYear: 'Итого / полгода',
totalPerYear: 'Итого / год',
noPrice: '—',
system: 'системная',
edit: 'Изменить',
save: 'Сохранить',
delete: 'Удалить',
confirmDelete: 'Удалить роль? Это действие необратимо.',
created: 'Роль создана.',
updated: 'Квота обновлена.',
updated: 'Роль обновлена.',
deleted: 'Роль удалена.',
billingEnabledLabel: 'Включить биллинг для этой роли',
billingBadge: 'биллинг',
},
plans: {
create: 'Создать тариф',
name: 'Название',
configCount: 'Количество конфигов',
sortOrder: 'Порядок сортировки',
enabled: 'Включён',
totalPerQuarter: 'Итого / 3 мес',
totalHalfYear: 'Итого / полгода',
totalPerYear: 'Итого / год',
noPrice: '—',
edit: 'Изменить',
save: 'Сохранить',
delete: 'Удалить',
confirmDelete: 'Удалить тариф? Это действие необратимо.',
created: 'Тариф создан.',
updated: 'Тариф обновлён.',
deleted: 'Тариф удалён.',
empty: 'Тарифы пока не созданы.',
disabled: 'выключен',
},
pricing: {
title: 'Справочная цена конфига',
pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
@@ -651,7 +668,7 @@ const resources = {
billingRequiredNotice: 'Your subscription has expired — creating new configs is unavailable until you renew.',
goToBilling: 'Go to payment',
created: 'Config created.',
quotaExceeded: 'Config quota reached for your role.',
quotaExceeded: 'Config quota reached for your plan.',
showLink: 'Link / QR',
rotate: 'Rotate',
rotated: 'Config rotated.',
@@ -673,6 +690,26 @@ const resources = {
},
},
plan: {
title: 'Plan',
changePlan: 'Change plan',
current: 'Current quota: {{count}} configs',
currentUnlimited: 'Current quota: unlimited',
custom: 'Custom amount',
customConfigCount: 'Number of configs',
submit: 'Apply',
changed: 'Plan changed.',
downgradeNotice: 'The new quota is lower than your current number of active configs — choose which to revoke:',
selectExactly: 'Select exactly {{count}} config(s) to revoke.',
topUpCreated: 'Plan increased. A top-up was created for the remaining paid period.',
goToBilling: 'Go to payment',
mustSelectConfigs: 'Select configs to revoke.',
perQuarter: '3 months: {{price}}',
perHalfYear: '6 months: {{price}}',
perYear: 'Year: {{price}}',
noPrice: '—',
},
billing: {
title: 'Subscription billing',
notApplicable: 'Billing does not apply to your role.',
@@ -717,8 +754,8 @@ const resources = {
confirmCancel: 'Cancel this payment request?',
requestCancelled: 'Request cancelled.',
awaitingAdminHint: 'The administrator has been notified and will verify the payment. Configs stay active until the request is decided.',
roleChangeTopUp: 'Role change top-up',
roleChangeTopUpHint: "Your new role costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.",
planChangeTopUp: 'Plan change top-up',
planChangeTopUpHint: "Your new plan costs more than the old one — this amount covers the price difference for the remaining part of your already-paid period; your subscription end date doesn't change.",
subscription: 'Subscription',
},
@@ -745,7 +782,6 @@ const resources = {
title: 'Support',
empty: 'You have no tickets yet.',
reportBug: 'Report a bug',
requestRole: 'Request a role',
requestExtension: 'Request an extension',
requestedDaysLabel: 'How many days you need',
requestedExtension: 'Requested a {{days}}-day extension',
@@ -754,32 +790,15 @@ const resources = {
messageLabel: 'Describe the issue or suggestion',
attachmentsLabel: 'Screenshots (optional, up to 5)',
filesSelected: '{{count}} file(s) selected',
existingRole: 'Existing role',
newRole: 'New role',
selectRole: 'Select a role',
roleOption: '{{name}} — configs: {{configs}}, IP: {{ip}}',
newRoleName: 'Role name',
newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
pricingTitle: 'Estimated cost',
pricingQuarter: '3 months: {{price}}',
pricingHalfYear: '6 months: {{price}}',
pricingYear: 'Year: {{price}}',
pricingDisclaimer: 'Prices are indicative only at this time.',
pricingDiscounted: '{{price}} ₽ (instead of {{original}} ₽, {{percent}}% off)',
justification: 'Justification',
ticketCreated: 'Ticket submitted.',
roleRequestPending: 'You already have a pending role request.',
reply: 'Reply',
replyPlaceholder: 'Write a comment…',
reopen: 'Reopen',
reopened: 'Ticket reopened.',
lastActivity: 'Last activity: {{date}}',
requestedExistingRole: 'Requested role: {{role}}',
requestedNewRole: 'Requested new role "{{name}}" (configs: {{configs}}, IPs: {{ip}})',
type: {
BugReport: 'Bug/suggestion',
RoleRequest: 'Role request',
ExtensionRequest: 'Extension request',
},
status: {
@@ -824,6 +843,7 @@ const resources = {
users: 'Users',
configs: 'All configs',
roles: 'Roles',
plans: 'Plans',
pricing: 'Pricing',
billing: 'Billing',
nodes: 'Nodes',
@@ -901,25 +921,39 @@ const resources = {
roles: {
create: 'Create role',
name: 'Name',
maxConfigs: 'Config quota',
maxConfigsHint: '−1 = unlimited.',
maxIpLimit: 'IP limit per config',
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
totalPerQuarter: 'Total / 3 months',
totalHalfYear: 'Total / 6 months',
totalPerYear: 'Total / year',
noPrice: '—',
system: 'system',
edit: 'Edit',
save: 'Save',
delete: 'Delete',
confirmDelete: 'Delete this role? This cannot be undone.',
created: 'Role created.',
updated: 'Quota updated.',
updated: 'Role updated.',
deleted: 'Role deleted.',
billingEnabledLabel: 'Enable billing for this role',
billingBadge: 'billing',
},
plans: {
create: 'Create plan',
name: 'Name',
configCount: 'Number of configs',
sortOrder: 'Sort order',
enabled: 'Enabled',
totalPerQuarter: 'Total / 3 months',
totalHalfYear: 'Total / 6 months',
totalPerYear: 'Total / year',
noPrice: '—',
edit: 'Edit',
save: 'Save',
delete: 'Delete',
confirmDelete: 'Delete this plan? This cannot be undone.',
created: 'Plan created.',
updated: 'Plan updated.',
deleted: 'Plan deleted.',
empty: 'No plans created yet.',
disabled: 'disabled',
},
pricing: {
title: 'Reference config price',
pricePerConfigPerQuarter: 'Price per config per month / billed every 3 months',