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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user