Implement extension request and gift functionalities in billing system
CI / Backend (build + test) (push) Successful in 1m27s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- Added new endpoints for creating and managing extension requests, allowing users to request billing period extensions.
- Implemented admin approval processes for extension requests via Telegram, including inline buttons for approval and rejection.
- Introduced a gifting feature for admins to grant additional billing days directly to users without a request.
- Updated the support ticket model to accommodate extension requests and their associated properties.
- Enhanced the Telegram notifier to inform admins of new extension requests and notify users of approval or rejection.
- Updated frontend components to support the new extension request and gifting functionalities, including user interfaces for managing these features.
- Revised API documentation to reflect the new endpoints and their usage in the billing context.
This commit is contained in:
Leonid Pershin
2026-07-19 05:30:11 +03:00
parent e088e302e9
commit 24cee9bb78
46 changed files with 2541 additions and 78 deletions
@@ -37,3 +37,7 @@ export function rejectPaymentRequest(id: string, reason?: string) {
body: { reason: reason ?? null },
})
}
export function grantBillingGift(userId: string, days: number) {
return apiRequest<void>('/admin/billing/gift', { method: 'POST', body: { userId, days } })
}
@@ -9,7 +9,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di
import { HttpError } from '@/shared/api/client'
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
import { TicketAttachmentImage } from '@/features/support/TicketAttachmentImage'
import { addAdminComment, approveRoleRequest, closeTicket, getAdminTicket, rejectRoleRequest, resolveTicket } from './api'
import {
addAdminComment,
approveExtensionRequest,
approveRoleRequest,
closeTicket,
getAdminTicket,
rejectExtensionRequest,
rejectRoleRequest,
resolveTicket,
} from './api'
const MAX_FILES = 5
@@ -57,7 +66,8 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
})
const approveMutation = useMutation({
mutationFn: () => approveRoleRequest(ticketId),
mutationFn: () =>
data?.type === 'ExtensionRequest' ? approveExtensionRequest(ticketId) : approveRoleRequest(ticketId),
onSuccess: async () => {
toast.success(t('admin.support.approved'))
await invalidate()
@@ -66,7 +76,8 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
})
const rejectMutation = useMutation({
mutationFn: () => rejectRoleRequest(ticketId),
mutationFn: () =>
data?.type === 'ExtensionRequest' ? rejectExtensionRequest(ticketId) : rejectRoleRequest(ticketId),
onSuccess: async () => {
toast.success(t('admin.support.rejected'))
await invalidate()
@@ -105,6 +116,12 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
</div>
)}
{data.type === 'ExtensionRequest' && (
<div className="rounded-md border border-border p-3 text-sm">
{t('support.requestedExtension', { days: data.requestedDays })}
</div>
)}
<div className="flex flex-col gap-3">
{data.comments.map((comment) => (
<div key={comment.id} className="rounded-md border border-border p-3">
@@ -126,7 +143,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
{data.status !== 'Closed' && (
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
{data.type === 'RoleRequest' && data.status === 'Open' ? (
{(data.type === 'RoleRequest' || data.type === 'ExtensionRequest') && data.status === 'Open' ? (
<>
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate()}>
{t('admin.support.approve')}
@@ -41,3 +41,14 @@ export function approveRoleRequest(ticketId: string) {
export function rejectRoleRequest(ticketId: string, reason?: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject`, { method: 'POST', body: { reason } })
}
export function approveExtensionRequest(ticketId: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/approve-extension`, { method: 'POST' })
}
export function rejectExtensionRequest(ticketId: string, reason?: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject-extension`, {
method: 'POST',
body: { reason },
})
}
@@ -9,6 +9,8 @@ import { Label } from '@/shared/ui/label'
import { Badge } from '@/shared/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { listRoles } from '@/features/admin/roles/api'
import { grantBillingGift } from '@/features/admin/billing/api'
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
import { useAuthStore } from '@/features/auth/store'
import type { UserSummaryDto } from '@/shared/api/types'
import {
@@ -25,6 +27,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
const { t } = useTranslation()
const queryClient = useQueryClient()
const [newPassword, setNewPassword] = useState('')
const [giftDays, setGiftDays] = useState('')
const currentUserId = useAuthStore((state) => state.user?.id)
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
@@ -59,6 +62,16 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
onError: () => toast.error(t('auth.genericError')),
})
const giftMutation = useMutation({
mutationFn: () => grantBillingGift(user.id, Number(giftDays)),
onSuccess: async () => {
toast.success(t('admin.users.giftGranted'))
setGiftDays('')
await invalidateUsers()
},
onError: () => toast.error(t('auth.genericError')),
})
const revokeMutation = useMutation({
mutationFn: (configId: string) => forceRevokeConfig(configId),
onSuccess: async () => {
@@ -139,6 +152,30 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
</div>
</div>
{user.billingEnabled && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.users.billingLabel')}</Label>
<PaidUntilBadge paidUntil={user.billingPaidUntil} />
<div className="mt-1 flex gap-2">
<Input
type="number"
min={1}
max={365}
value={giftDays}
onChange={(e) => setGiftDays(e.target.value)}
placeholder={t('admin.users.giftDaysPlaceholder')}
/>
<Button
variant="outline"
disabled={!(Number(giftDays) > 0) || giftMutation.isPending}
onClick={() => giftMutation.mutate()}
>
{t('admin.users.giftGrant')}
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-2">
<Label>{t('admin.users.configs')}</Label>
{configsQuery.data?.length === 0 && <p className="text-sm text-muted-foreground">{t('configs.empty')}</p>}
@@ -0,0 +1,89 @@
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 { Textarea } from '@/shared/ui/textarea'
import { Label } from '@/shared/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { HttpError } from '@/shared/api/client'
import { createExtensionRequestTicket } from './api'
export function CreateExtensionRequestDialog() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [requestedDays, setRequestedDays] = useState('')
const [justification, setJustification] = useState('')
const resetForm = () => {
setRequestedDays('')
setJustification('')
}
const mutation = useMutation({
mutationFn: () => createExtensionRequestTicket(Number(requestedDays), justification.trim()),
onSuccess: async () => {
toast.success(t('support.ticketCreated'))
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
setOpen(false)
resetForm()
},
onError: (error) => {
const message =
error instanceof HttpError && error.status === 409
? t('support.extensionRequestPending')
: error instanceof HttpError
? error.detail
: t('auth.genericError')
toast.error(message)
},
})
const canSubmit = Number(requestedDays) > 0 && justification.trim().length > 0
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('support.requestExtension')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('support.requestExtension')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) mutation.mutate()
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="requested-days">{t('support.requestedDaysLabel')}</Label>
<Input
id="requested-days"
type="number"
min={1}
max={365}
value={requestedDays}
onChange={(e) => setRequestedDays(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="extension-justification">{t('support.justification')}</Label>
<Textarea
id="extension-justification"
value={justification}
onChange={(e) => setJustification(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
{t('support.submit')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
@@ -4,7 +4,9 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { getMyBillingStatus } from '@/features/billing/api'
import { CreateBugReportDialog } from './CreateBugReportDialog'
import { CreateExtensionRequestDialog } from './CreateExtensionRequestDialog'
import { CreateRoleRequestDialog } from './CreateRoleRequestDialog'
import { TicketDetailDialog } from './TicketDetailDialog'
import { TicketStatusBadge } from './TicketStatusBadge'
@@ -23,12 +25,14 @@ export function SupportTicketList() {
queryKey: ['my-tickets', page],
queryFn: () => listMyTickets(undefined, undefined, page, PAGE_SIZE),
})
const billingStatusQuery = useQuery({ queryKey: ['my-billing-status'], queryFn: getMyBillingStatus })
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap gap-2">
<CreateBugReportDialog />
<CreateRoleRequestDialog />
{billingStatusQuery.data?.billingEnabled && <CreateExtensionRequestDialog />}
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
@@ -75,6 +75,12 @@ export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: strin
</div>
)}
{data.type === 'ExtensionRequest' && (
<div className="rounded-md border border-border p-3 text-sm">
{t('support.requestedExtension', { days: data.requestedDays })}
</div>
)}
<div className="flex flex-col gap-3">
{data.comments.map((comment) => (
<div key={comment.id} className="rounded-md border border-border p-3">
+7
View File
@@ -50,6 +50,13 @@ export function createRoleRequestTicket(payload: {
return apiRequest<TicketDetailDto>('/support/tickets/role-requests', { method: 'POST', body: payload })
}
export function createExtensionRequestTicket(requestedDays: number, justification: string) {
return apiRequest<TicketDetailDto>('/support/tickets/extension-requests', {
method: 'POST',
body: { requestedDays, justification },
})
}
export function addTicketComment(ticketId: string, body: string, files: File[]) {
const formData = new FormData()
formData.set('body', body)
+2 -1
View File
@@ -310,7 +310,7 @@ export type AuditLogDto = {
createdAt: string
}
export type TicketType = 'BugReport' | 'RoleRequest'
export type TicketType = 'BugReport' | 'RoleRequest' | 'ExtensionRequest'
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
export type TicketAttachmentDto = {
@@ -356,6 +356,7 @@ export type TicketDetailDto = {
proposedRoleName: string | null
proposedMaxConfigs: number | null
proposedMaxIpLimit: number | null
requestedDays: number | null
createdAt: string
comments: TicketCommentDto[]
}
+16
View File
@@ -165,6 +165,10 @@ const resources = {
empty: 'У вас пока нет обращений.',
reportBug: 'Сообщить об ошибке',
requestRole: 'Запросить роль',
requestExtension: 'Попросить о продлении',
requestedDaysLabel: 'Сколько дней нужно',
requestedExtension: 'Запрошено продление на {{days}} дн.',
extensionRequestPending: 'У вас уже есть необработанная заявка на продление.',
submit: 'Отправить',
messageLabel: 'Опишите проблему или предложение',
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
@@ -194,6 +198,7 @@ const resources = {
type: {
BugReport: 'Ошибка/предложение',
RoleRequest: 'Заявка на роль',
ExtensionRequest: 'Заявка на продление',
},
status: {
Open: 'Открыт',
@@ -274,6 +279,9 @@ const resources = {
delete: 'Удалить пользователя',
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
deleted: 'Пользователь удалён.',
giftDaysPlaceholder: 'Дней',
giftGrant: 'Подарить',
giftGranted: 'Дни подписки подарены пользователю.',
},
configs: {
searchPlaceholder: 'Поиск по email в панели или метке',
@@ -681,6 +689,10 @@ const resources = {
empty: 'You have no tickets yet.',
reportBug: 'Report a bug',
requestRole: 'Request a role',
requestExtension: 'Request an extension',
requestedDaysLabel: 'How many days you need',
requestedExtension: 'Requested a {{days}}-day extension',
extensionRequestPending: 'You already have a pending extension request.',
submit: 'Submit',
messageLabel: 'Describe the issue or suggestion',
attachmentsLabel: 'Screenshots (optional, up to 5)',
@@ -710,6 +722,7 @@ const resources = {
type: {
BugReport: 'Bug/suggestion',
RoleRequest: 'Role request',
ExtensionRequest: 'Extension request',
},
status: {
Open: 'Open',
@@ -790,6 +803,9 @@ const resources = {
delete: 'Delete user',
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
deleted: 'User deleted.',
giftDaysPlaceholder: 'Days',
giftGrant: 'Grant',
giftGranted: 'Subscription days granted to the user.',
},
configs: {
searchPlaceholder: 'Search by panel email or label',