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