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

- Introduced billing capabilities, allowing users to request payments for subscription periods (3/6/12 months) with admin approval via Telegram.
- Updated role management to include a `BillingEnabled` property, preventing billing for admin roles.
- Enhanced the `CreateRoleCommand` and `UpdateRoleCommand` to accept billing parameters, ensuring proper handling during role creation and updates.
- Added new endpoints for billing management and integrated billing checks into VPN config creation to enforce payment requirements.
- Updated related services, models, and tests to support the new billing features, ensuring comprehensive coverage and functionality.
- Enhanced documentation to reflect the new billing processes and role management changes.
This commit is contained in:
Leonid Pershin
2026-07-19 01:38:16 +03:00
parent b980dc6cef
commit b2ae358250
106 changed files with 6018 additions and 66 deletions
@@ -0,0 +1,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 { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { HttpError } from '@/shared/api/client'
import type { RoleDto } from '@/shared/api/types'
import { getBillingSettings } from '@/features/admin/billing/api'
import { createRole, updateRole } from './api'
/** Без role — диалог создания (кнопка-триггер); с role — диалог редактирования квоты (управляется извне). */
@@ -24,8 +26,25 @@ export function RoleFormDialog({
const [name, setName] = useState(role?.name ?? '')
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2))
const [billingEnabled, setBillingEnabled] = useState(role?.billingEnabled ?? false)
const [internalOpen, setInternalOpen] = useState(false)
// Роль 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 dialogOpen = isControlled ? open : internalOpen
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
@@ -33,8 +52,8 @@ export function RoleFormDialog({
const mutation = useMutation({
mutationFn: () =>
role
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit))
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit)),
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit), billingEnabled)
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit), billingEnabled),
onSuccess: async () => {
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
@@ -42,8 +61,9 @@ export function RoleFormDialog({
setName('')
setMaxConfigs('3')
setMaxIpLimit('2')
setBillingEnabled(false)
},
onError: () => toast.error(t('auth.genericError')),
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
return (
@@ -80,6 +100,16 @@ export function RoleFormDialog({
<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>
</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())}>
{role ? t('admin.roles.save') : t('admin.roles.create')}
</Button>
+10 -4
View File
@@ -5,12 +5,18 @@ export function listRoles() {
return apiRequest<RoleDto[]>('/admin/roles')
}
export function createRole(name: string, maxConfigs: number, maxIpLimit: number) {
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } })
export function createRole(name: string, maxConfigs: number, maxIpLimit: number, billingEnabled: boolean) {
return apiRequest<RoleDto>('/admin/roles', {
method: 'POST',
body: { name, maxConfigs, maxIpLimit, billingEnabled },
})
}
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) {
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } })
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number, billingEnabled: boolean) {
return apiRequest<RoleDto>(`/admin/roles/${id}`, {
method: 'PUT',
body: { maxConfigs, maxIpLimit, billingEnabled },
})
}
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>
)
}
+22
View File
@@ -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' })
}
+42
View File
@@ -16,6 +16,7 @@ import { Route as NewsRouteImport } from './routes/news'
import { Route as LoginRouteImport } from './routes/login'
import { Route as InstructionsRouteImport } from './routes/instructions'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as BillingRouteImport } from './routes/billing'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/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 AdminInstructionsRouteImport } from './routes/admin/instructions'
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 AdminAppsRouteImport } from './routes/admin/apps'
import { Route as AdminActivationRouteImport } from './routes/admin/activation'
@@ -67,6 +69,11 @@ const DashboardRoute = DashboardRouteImport.update({
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)
const BillingRoute = BillingRouteImport.update({
id: '/billing',
path: '/billing',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
@@ -127,6 +134,11 @@ const AdminConfigsRoute = AdminConfigsRouteImport.update({
path: '/configs',
getParentRoute: () => AdminRoute,
} as any)
const AdminBillingRoute = AdminBillingRouteImport.update({
id: '/billing',
path: '/billing',
getParentRoute: () => AdminRoute,
} as any)
const AdminAuditRoute = AdminAuditRouteImport.update({
id: '/audit',
path: '/audit',
@@ -146,6 +158,7 @@ const AdminActivationRoute = AdminActivationRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRouteWithChildren
'/billing': typeof BillingRoute
'/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
@@ -156,6 +169,7 @@ export interface FileRoutesByFullPath {
'/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute
'/admin/billing': typeof AdminBillingRoute
'/admin/configs': typeof AdminConfigsRoute
'/admin/instructions': typeof AdminInstructionsRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
@@ -169,6 +183,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/billing': typeof BillingRoute
'/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
@@ -179,6 +194,7 @@ export interface FileRoutesByTo {
'/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute
'/admin/billing': typeof AdminBillingRoute
'/admin/configs': typeof AdminConfigsRoute
'/admin/instructions': typeof AdminInstructionsRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
@@ -194,6 +210,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/admin': typeof AdminRouteWithChildren
'/billing': typeof BillingRoute
'/dashboard': typeof DashboardRoute
'/instructions': typeof InstructionsRoute
'/login': typeof LoginRoute
@@ -204,6 +221,7 @@ export interface FileRoutesById {
'/admin/activation': typeof AdminActivationRoute
'/admin/apps': typeof AdminAppsRoute
'/admin/audit': typeof AdminAuditRoute
'/admin/billing': typeof AdminBillingRoute
'/admin/configs': typeof AdminConfigsRoute
'/admin/instructions': typeof AdminInstructionsRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
@@ -220,6 +238,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/admin'
| '/billing'
| '/dashboard'
| '/instructions'
| '/login'
@@ -230,6 +249,7 @@ export interface FileRouteTypes {
| '/admin/activation'
| '/admin/apps'
| '/admin/audit'
| '/admin/billing'
| '/admin/configs'
| '/admin/instructions'
| '/admin/maintenance'
@@ -243,6 +263,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/billing'
| '/dashboard'
| '/instructions'
| '/login'
@@ -253,6 +274,7 @@ export interface FileRouteTypes {
| '/admin/activation'
| '/admin/apps'
| '/admin/audit'
| '/admin/billing'
| '/admin/configs'
| '/admin/instructions'
| '/admin/maintenance'
@@ -267,6 +289,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/admin'
| '/billing'
| '/dashboard'
| '/instructions'
| '/login'
@@ -277,6 +300,7 @@ export interface FileRouteTypes {
| '/admin/activation'
| '/admin/apps'
| '/admin/audit'
| '/admin/billing'
| '/admin/configs'
| '/admin/instructions'
| '/admin/maintenance'
@@ -292,6 +316,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRouteWithChildren
BillingRoute: typeof BillingRoute
DashboardRoute: typeof DashboardRoute
InstructionsRoute: typeof InstructionsRoute
LoginRoute: typeof LoginRoute
@@ -352,6 +377,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
'/billing': {
id: '/billing'
path: '/billing'
fullPath: '/billing'
preLoaderRoute: typeof BillingRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
@@ -436,6 +468,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminConfigsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/billing': {
id: '/admin/billing'
path: '/billing'
fullPath: '/admin/billing'
preLoaderRoute: typeof AdminBillingRouteImport
parentRoute: typeof AdminRoute
}
'/admin/audit': {
id: '/admin/audit'
path: '/audit'
@@ -464,6 +503,7 @@ interface AdminRouteChildren {
AdminActivationRoute: typeof AdminActivationRoute
AdminAppsRoute: typeof AdminAppsRoute
AdminAuditRoute: typeof AdminAuditRoute
AdminBillingRoute: typeof AdminBillingRoute
AdminConfigsRoute: typeof AdminConfigsRoute
AdminInstructionsRoute: typeof AdminInstructionsRoute
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
@@ -480,6 +520,7 @@ const AdminRouteChildren: AdminRouteChildren = {
AdminActivationRoute: AdminActivationRoute,
AdminAppsRoute: AdminAppsRoute,
AdminAuditRoute: AdminAuditRoute,
AdminBillingRoute: AdminBillingRoute,
AdminConfigsRoute: AdminConfigsRoute,
AdminInstructionsRoute: AdminInstructionsRoute,
AdminMaintenanceRoute: AdminMaintenanceRoute,
@@ -497,6 +538,7 @@ const AdminRouteWithChildren = AdminRoute._addFileChildren(AdminRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRouteWithChildren,
BillingRoute: BillingRoute,
DashboardRoute: DashboardRoute,
InstructionsRoute: InstructionsRoute,
LoginRoute: LoginRoute,
+16
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { Link, Outlet, createRootRoute, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Menu, X } from 'lucide-react'
import { useTheme, type Theme } from '@/theme/ThemeProvider'
@@ -9,6 +10,7 @@ import { Button } from '@/shared/ui/button'
import { useAuthStore } from '@/features/auth/store'
import { bootstrapSession, clearSession, logout } from '@/features/auth/api'
import { TelegramLinkWarningBanner } from '@/features/telegram/TelegramLinkWarningBanner'
import { getMyBillingStatus } from '@/features/billing/api'
export const Route = createRootRoute({ component: RootLayout })
@@ -19,6 +21,15 @@ function RootLayout() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
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(() => {
void bootstrapSession()
}, [])
@@ -55,6 +66,11 @@ function RootLayout() {
<Link to="/support" className="text-muted-foreground hover:text-foreground">
{t('nav.support')}
</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">
+1
View File
@@ -12,6 +12,7 @@ const TABS = [
{ to: '/admin/configs', key: 'configs' },
{ to: '/admin/roles', key: 'roles' },
{ to: '/admin/pricing', key: 'pricing' },
{ to: '/admin/billing', key: 'billing' },
{ to: '/admin/nodes', key: 'nodes' },
{ to: '/admin/apps', key: 'apps' },
{ to: '/admin/instructions', key: 'instructions' },
+213
View File
@@ -0,0 +1,213 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client'
import type { PaymentRequestStatus } from '@/shared/api/types'
import { BillingSettingsEditor } from '@/features/admin/billing/BillingSettingsEditor'
import {
confirmPaymentRequest,
getBillingSettings,
listPaymentRequests,
rejectPaymentRequest,
} from '@/features/admin/billing/api'
export const Route = createFileRoute('/admin/billing')({ component: AdminBillingPage })
const STATUS_FILTERS: (PaymentRequestStatus | 'All')[] = [
'AwaitingConfirmation',
'AwaitingPayment',
'Confirmed',
'Rejected',
'Cancelled',
'All',
]
function AdminBillingPage() {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('admin.billing.settingsTitle')}</CardTitle>
</CardHeader>
<CardContent>
<SettingsSection />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('admin.billing.requestsTitle')}</CardTitle>
</CardHeader>
<CardContent>
<RequestsSection />
</CardContent>
</Card>
</div>
)
}
function SettingsSection() {
const { t } = useTranslation()
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-billing-settings'],
queryFn: getBillingSettings,
})
if (isLoading) return <p className="text-sm text-muted-foreground"></p>
if (isError || !data) {
return (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)
}
return <BillingSettingsEditor settings={data} />
}
function RequestsSection() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [status, setStatus] = useState<PaymentRequestStatus | 'All'>('AwaitingConfirmation')
const [page, setPage] = useState(1)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['admin-payment-requests', status, page],
queryFn: () => listPaymentRequests(status === 'All' ? undefined : status, page, 20),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-payment-requests'] })
const confirmMutation = useMutation({
mutationFn: confirmPaymentRequest,
onSuccess: async () => {
toast.success(t('admin.billing.confirmed'))
await invalidate()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const rejectMutation = useMutation({
mutationFn: (id: string) => {
const reason = prompt(t('admin.billing.rejectReasonPrompt')) ?? undefined
return rejectPaymentRequest(id, reason || undefined)
},
onSuccess: async () => {
toast.success(t('admin.billing.rejected'))
await invalidate()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
return (
<div className="flex flex-col gap-4">
<Select
value={status}
onValueChange={(v) => {
setStatus(v as PaymentRequestStatus | 'All')
setPage(1)
}}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_FILTERS.map((s) => (
<SelectItem key={s} value={s}>
{s === 'All' ? t('admin.billing.allStatuses') : t(`admin.billing.status.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data && data.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.billing.empty')}</p>}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="py-2 font-medium">{t('admin.billing.user')}</th>
<th className="py-2 font-medium">{t('admin.billing.period')}</th>
<th className="py-2 font-medium">{t('admin.billing.amount')}</th>
<th className="py-2 font-medium">{t('admin.billing.statusLabel')}</th>
<th className="py-2 font-medium">{t('admin.billing.created')}</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{data.items.map((request) => (
<tr key={request.id} className="border-b border-border">
<td className="py-2">{request.userName}</td>
<td className="py-2">{t(`billing.periods.${request.period}`)}</td>
<td className="py-2">{request.amountSnapshot} </td>
<td className="py-2">
<Badge variant={request.status === 'AwaitingConfirmation' ? 'warning' : 'outline'}>
{t(`admin.billing.status.${request.status}`)}
</Badge>
</td>
<td className="py-2">{new Date(request.createdAt).toLocaleDateString()}</td>
<td className="py-2 text-right">
{(request.status === 'AwaitingPayment' || request.status === 'AwaitingConfirmation') && (
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={confirmMutation.isPending}
onClick={() => confirmMutation.mutate(request.id)}
>
{t('admin.billing.confirm')}
</Button>
<Button
size="sm"
variant="outline"
disabled={rejectMutation.isPending}
onClick={() => rejectMutation.mutate(request.id)}
>
{t('admin.billing.reject')}
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data && (
<div className="flex justify-end gap-2 text-sm">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * 20 >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
)}
</div>
)
}
+2 -1
View File
@@ -69,7 +69,8 @@ function AdminRolesPage() {
{data.map((role) => (
<tr key={role.id} className="border-b border-border">
<td className="py-2">
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}
{role.name} {role.isSystem && <Badge variant="outline">{t('admin.roles.system')}</Badge>}{' '}
{role.billingEnabled && <Badge variant="success">{t('admin.roles.billingBadge')}</Badge>}
</td>
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
+73
View File
@@ -0,0 +1,73 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useRequireAuth } from '@/features/auth/guards'
import { ActivationGate } from '@/features/activation/ActivationGate'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { getMyBillingStatus } from '@/features/billing/api'
import { PaymentRequestPanel } from '@/features/billing/PaymentRequestPanel'
export const Route = createFileRoute('/billing')({ component: BillingPage })
function BillingPage() {
const { isReady } = useRequireAuth()
if (!isReady) return null
return (
<ActivationGate>
<BillingContent />
</ActivationGate>
)
}
function BillingContent() {
const { t } = useTranslation()
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['my-billing-status'],
queryFn: getMyBillingStatus,
})
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">{t('billing.title')}</h1>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data && !data.billingEnabled && (
<p className="text-sm text-muted-foreground">{t('billing.notApplicable')}</p>
)}
{data?.billingEnabled && (
<>
<Card>
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-base">{t('billing.status')}</CardTitle>
{data.suspended && <Badge variant="destructive">{t('billing.suspended')}</Badge>}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{data.paidUntil
? t('billing.paidUntil', { date: new Date(data.paidUntil).toLocaleDateString() })
: t('billing.neverPaid')}
</p>
</CardContent>
</Card>
<PaymentRequestPanel status={data} />
</>
)}
</div>
)
}
+1
View File
@@ -3360,6 +3360,7 @@ export interface components {
/** Format: int32 */
maxIpLimit: number | string;
isSystem: boolean;
billingEnabled: boolean;
};
StatsDto: {
/** Format: int32 */
+43
View File
@@ -173,6 +173,7 @@ export type RoleDto = {
maxConfigs: number
maxIpLimit: number
isSystem: boolean
billingEnabled: boolean
}
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль.
@@ -183,6 +184,48 @@ export type PricingSettingsDto = {
pricePerConfigPerYear: number | null
}
export type PaymentPeriod = 'Quarter' | 'HalfYear' | 'Year'
export type PaymentRequestStatus =
| 'AwaitingPayment'
| 'AwaitingConfirmation'
| 'Confirmed'
| 'Rejected'
| 'Cancelled'
export type PaymentRequestDto = {
id: string
period: PaymentPeriod
amountSnapshot: number
status: PaymentRequestStatus
createdAt: string
}
export type BillingStatusDto = {
billingEnabled: boolean
paidUntil: string | null
suspended: boolean
requisitesText: string
activeRequest: PaymentRequestDto | null
}
/** Реквизиты + грейс-период для новых billing-пользователей (см. BillingSettings.DefaultGraceDays). */
export type BillingSettingsDto = {
requisitesText: string
graceDays: number
/** Начальное состояние чекбокса "Включить биллинг" в диалоге создания новой роли. */
defaultBillingEnabledForNewRoles: boolean
}
export type AdminPaymentRequestDto = {
id: string
userId: string
userName: string
period: PaymentPeriod
amountSnapshot: number
status: PaymentRequestStatus
createdAt: string
}
export type ActivationRequestAdminDto = {
id: string
userId: string
+134
View File
@@ -50,6 +50,7 @@ const resources = {
instructions: 'Инструкции',
news: 'Новости',
support: 'Поддержка',
billing: 'Оплата',
settings: 'Настройки',
admin: 'Админка',
logout: 'Выйти',
@@ -100,6 +101,40 @@ const resources = {
},
},
billing: {
title: 'Оплата подписки',
notApplicable: 'Биллинг не применяется к вашей роли.',
status: 'Статус оплаты',
suspended: 'Приостановлено',
paidUntil: 'Оплачено до {{date}}',
neverPaid: 'Оплата ещё не производилась.',
newRequest: 'Оформить оплату',
period: 'Период',
periods: {
Quarter: '3 месяца',
HalfYear: 'Полгода',
Year: 'Год',
},
createRequest: 'Создать заявку',
requestCreated: 'Заявка создана.',
activeRequest: 'Заявка на оплату',
requestStatus: {
AwaitingPayment: 'Ожидает оплаты',
AwaitingConfirmation: 'На проверке у администратора',
},
requisites: 'Реквизиты для оплаты',
requisitesMissing: 'Реквизиты ещё не настроены администратором.',
sendToTelegram: 'Отправить в Telegram',
requisitesSent: 'Реквизиты отправлены в Telegram.',
telegramNotLinked: 'Сначала привяжите Telegram в настройках.',
iPaid: 'Я оплатил',
markedPaid: 'Отмечено — администратор проверит оплату.',
cancelRequest: 'Отменить',
confirmCancel: 'Отменить заявку на оплату?',
requestCancelled: 'Заявка отменена.',
awaitingAdminHint: 'Администратор уведомлён и проверит оплату. Конфиги не отключатся, пока заявка не решена.',
},
instructions: {
title: 'Инструкции по подключению',
appsTitle: 'Приложения',
@@ -197,6 +232,7 @@ const resources = {
configs: 'Все конфиги',
roles: 'Роли',
pricing: 'Цены',
billing: 'Оплата',
nodes: 'Ноды',
apps: 'Приложения',
instructions: 'Инструкции',
@@ -271,6 +307,8 @@ const resources = {
created: 'Роль создана.',
updated: 'Квота обновлена.',
deleted: 'Роль удалена.',
billingEnabledLabel: 'Включить биллинг для этой роли',
billingBadge: 'биллинг',
},
pricing: {
title: 'Справочная цена конфига',
@@ -285,6 +323,35 @@ const resources = {
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
updated: 'Цена обновлена.',
},
billing: {
settingsTitle: 'Настройки биллинга',
requisitesText: 'Реквизиты для оплаты',
requisitesTextHint: 'Показываются пользователю вместе с заявкой на оплату.',
graceDays: 'Грейс-период (дней)',
graceDaysHint: 'Сколько дней доступ активен после назначения billing-роли, пока не поступит первая оплата.',
defaultBillingEnabledForNewRolesLabel: 'Новые роли по умолчанию с включённым биллингом',
settingsUpdated: 'Настройки биллинга обновлены.',
requestsTitle: 'Заявки на оплату',
allStatuses: 'Все статусы',
user: 'Пользователь',
period: 'Период',
amount: 'Сумма',
statusLabel: 'Статус',
created: 'Создана',
empty: 'Заявок не найдено.',
confirm: 'Подтвердить',
reject: 'Отклонить',
confirmed: 'Оплата подтверждена.',
rejected: 'Заявка отклонена.',
rejectReasonPrompt: 'Причина отклонения (необязательно):',
status: {
AwaitingPayment: 'Ожидает оплаты',
AwaitingConfirmation: 'На проверке',
Confirmed: 'Подтверждена',
Rejected: 'Отклонена',
Cancelled: 'Отменена',
},
},
nodes: {
create: 'Добавить ноду',
name: 'Название',
@@ -492,6 +559,7 @@ const resources = {
instructions: 'Instructions',
news: 'News',
support: 'Support',
billing: 'Billing',
settings: 'Settings',
admin: 'Admin',
logout: 'Log out',
@@ -542,6 +610,40 @@ const resources = {
},
},
billing: {
title: 'Subscription billing',
notApplicable: 'Billing does not apply to your role.',
status: 'Payment status',
suspended: 'Suspended',
paidUntil: 'Paid until {{date}}',
neverPaid: 'No payment has been made yet.',
newRequest: 'Set up payment',
period: 'Period',
periods: {
Quarter: '3 months',
HalfYear: 'Half a year',
Year: 'A year',
},
createRequest: 'Create request',
requestCreated: 'Request created.',
activeRequest: 'Payment request',
requestStatus: {
AwaitingPayment: 'Awaiting payment',
AwaitingConfirmation: 'Under admin review',
},
requisites: 'Payment details',
requisitesMissing: 'The administrator has not set up payment details yet.',
sendToTelegram: 'Send to Telegram',
requisitesSent: 'Payment details sent to Telegram.',
telegramNotLinked: 'Link Telegram in settings first.',
iPaid: 'I paid',
markedPaid: 'Marked — the administrator will verify the payment.',
cancelRequest: 'Cancel',
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.',
},
instructions: {
title: 'Connection instructions',
appsTitle: 'Apps',
@@ -639,6 +741,7 @@ const resources = {
configs: 'All configs',
roles: 'Roles',
pricing: 'Pricing',
billing: 'Billing',
nodes: 'Nodes',
apps: 'Apps',
instructions: 'Instructions',
@@ -713,6 +816,8 @@ const resources = {
created: 'Role created.',
updated: 'Quota updated.',
deleted: 'Role deleted.',
billingEnabledLabel: 'Enable billing for this role',
billingBadge: 'billing',
},
pricing: {
title: 'Reference config price',
@@ -727,6 +832,35 @@ const resources = {
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
updated: 'Pricing updated.',
},
billing: {
settingsTitle: 'Billing settings',
requisitesText: 'Payment details',
requisitesTextHint: 'Shown to the user alongside their payment request.',
graceDays: 'Grace period (days)',
graceDaysHint: 'How many days access stays active after a billing role is assigned, before the first payment is due.',
defaultBillingEnabledForNewRolesLabel: 'New roles default to billing enabled',
settingsUpdated: 'Billing settings updated.',
requestsTitle: 'Payment requests',
allStatuses: 'All statuses',
user: 'User',
period: 'Period',
amount: 'Amount',
statusLabel: 'Status',
created: 'Created',
empty: 'No requests found.',
confirm: 'Confirm',
reject: 'Reject',
confirmed: 'Payment confirmed.',
rejected: 'Request rejected.',
rejectReasonPrompt: 'Rejection reason (optional):',
status: {
AwaitingPayment: 'Awaiting payment',
AwaitingConfirmation: 'Under review',
Confirmed: 'Confirmed',
Rejected: 'Rejected',
Cancelled: 'Cancelled',
},
},
nodes: {
create: 'Add node',
name: 'Name',