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,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>
|
||||
|
||||
Reference in New Issue
Block a user