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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3360,6 +3360,7 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
maxIpLimit: number | string;
|
||||
isSystem: boolean;
|
||||
billingEnabled: boolean;
|
||||
};
|
||||
StatsDto: {
|
||||
/** Format: int32 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user