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
+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>
)
}